Beginner's guide to Javascript LocalStorage

Beginner's guide to Javascript LocalStorage

LocalStorage and sessionStorage are not really data stores. They are facades over a browser-managed backing store, with quotas, scoping rules, security implications, and hidden behaviors that the API surface does not hint at.

This is a deep dive on what those facades actually wrap. We will start at the surface (the Storage interface itself), work down through the LevelDB and SQLite files the browser keeps on disk, climb back up through the security model and the scoping rules, look at what people have built on top to compensate for the rough edges, and end with a decision framework for when these APIs are the right answer versus when they are the lazy answer.

If you have only ever used localStorage.setItem and localStorage.getItem, you will leave with a much sharper picture of what is actually happening when you call them. If you have been building on top of localStorage for years, you will still find a few hidden behaviors worth knowing about. The topic is small. The surface area underneath is not.

Let's dig in.


What localStorage Actually Is

window.localStorage returns a Storage object. The Storage interface is defined in the HTML spec, and its full surface is:

interface Storage {
  readonly length: number;
  key(index: number): string | null;
  getItem(key: string): string | null;
  setItem(key: string, value: string): void;
  removeItem(key: string): void;
  clear(): void;
  [name: string]: any;  // index signature for the property-access form
}

That's it. Eight things, one of which is the index signature that lets you write localStorage.cities instead of localStorage.getItem('cities'). Every value is a string. Every operation is synchronous. There are no transactions, no indexes, no schema, no observers beyond the cross-tab storage event.

The thinness of the API is the first thing worth understanding. localStorage is not a database. It is a key/value bag with a Map-shaped interface, and the browser implements the actual persistence behind it however it wants. The spec defines the contract; the implementation is the browser's business.

This matters because most discussions of localStorage mix the contract (what the spec says) with the implementation (what your specific Chromium/Firefox/Safari build does). Quotas, eviction behavior, performance, on-disk format: these are all implementation choices that vary between browsers and across versions of the same browser. The spec gives you very few guarantees.


What the Browser Actually Stores

The Storage object is a facade. The data lives in a backing store managed by the browser's storage layer. Where, exactly, depends on the browser.

Chromium (Chrome, Edge, Brave, Opera): localStorage is stored in a LevelDB database at:

~/Library/Application Support/Google/Chrome/Default/Local Storage/leveldb/   (macOS)
~/.config/google-chrome/Default/Local Storage/leveldb/                       (Linux)
%LOCALAPPDATA%\Google\Chrome\User Data\Default\Local Storage\leveldb\        (Windows)

LevelDB is a key/value store. Each origin's localStorage is stored as a set of entries keyed by _<origin>\x00\x01<key>, with the value as the raw UTF-16 string. You can open the LevelDB folder with a tool like leveldb-cli and read every site's localStorage from disk. There is no encryption at rest in the default install. If someone has read access to your home directory, they have read access to every site's localStorage.

Firefox: until Firefox 92 (2021), localStorage was stored in a SQLite database at webappsstore.sqlite. As of Firefox 92, the new LSNG (Local Storage Next Generation) backend stores localStorage in a per-origin directory under the profile's storage/default/ folder, using a binary format. The change was driven by performance: the SQLite-backed implementation held a global lock that serialized writes across origins.

Safari (WebKit): localStorage is stored in SQLite files under the profile directory, with the origin encoded in the file path. WebKit also implements localStorage with a write-back cache, so writes are not always immediately durable to disk; this matters if the browser crashes between the setItem call returning and the flush.

The takeaway: localStorage.setItem('x', 'y') is not a memory write. It triggers a synchronous round-trip through the browser's storage layer, which in Chromium hits LevelDB on disk. On a fast machine with a warm cache, this takes hundreds of microseconds. On a slow machine with a cold cache, it can take milliseconds. Every setItem call blocks the main thread for the duration of the write.

This is the first hidden behavior. The synchronous string-key API hides what is actually a synchronous disk operation, and that has real consequences for performance.


The Scoping Rules: Origin, Not Domain

Storage is scoped by origin, defined as the tuple (scheme, host, port). Not domain. Not URL. Origin.

https://example.com and http://example.com have different storage. So do https://example.com:443 and https://example.com:8443. So do https://example.com and https://www.example.com, because example.com and www.example.com are different hosts even though they look related.

A common misconception is that storage is shared across "all subdomains of an application." It is not. By default you cannot share storage across subdomains. Each subdomain has its own origin and its own storage bucket. There is no localStorage-level mechanism for sharing across subdomains. If you need shared state across app.example.com and marketing.example.com, you need a different layer: a cookie scoped to .example.com, a shared backend, or a cross-frame messaging dance using postMessage against a hidden iframe loaded from a common origin.

Storage partitioning: as of 2023, all major browsers also partition third-party storage by the top-level site. If analytics.example.com is loaded as an iframe inside news.com AND as an iframe inside shopping.com, those two contexts get different localStorage buckets, even though they are the same origin. The partitioning key is (top-level-site, embedded-origin). This was driven by privacy: it prevents third parties from correlating user activity across the sites that embed them.

This breaks a lot of older third-party widgets. If you build something that relies on shared state across embedding contexts, partitioning will silently isolate the buckets and you will spend an afternoon figuring out why.


localStorage vs sessionStorage: The Lifecycle Difference

Both implement the same Storage interface. The difference is the lifecycle of the backing store.

localStorage: persists across browser restarts. The data is durable until explicitly cleared (by the user, by the page, or by the browser's quota-driven eviction). One bucket per origin per profile.

sessionStorage: persists for the lifetime of the top-level browsing context. Closing the tab clears it. Closing the window clears it. Browser restart clears it. Crucially, the scoping is finer than "per tab", and this trips people up:

  • Open example.com in tab A. sessionStorage bucket A1 is created.
  • Open example.com in tab B (separate tab, manually navigated). sessionStorage bucket B1 is created. A1 and B1 are different.
  • In tab A, Cmd+Click a link to open it in tab C. sessionStorage from A is copied into C. C now has its own bucket initialised from A's contents, but changes in C do not propagate to A and vice versa.
  • iframes nested inside a page share the sessionStorage of their top-level context, not of their iframe's origin.

The third bullet is the surprising one. Duplicating a tab inherits a snapshot of sessionStorage. After that point the two buckets diverge. This is documented behavior, but it is not intuitive, and it produces subtle bugs in apps that assume sessionStorage is strictly "per tab and only this tab".

When to use which:

  • localStorage: things that should persist when the user closes the browser and comes back (theme preference, dismissed-banner flags, draft content the user explicitly hasn't submitted).
  • sessionStorage: things scoped to one task in one tab (a multi-step form's intermediate state, a "from where did the user arrive at this page" referrer that should not leak across tabs, an upload-in-progress marker).

If you find yourself asking "which one do I want", the answer is usually sessionStorage for in-flight state and localStorage for explicit preferences. Defaulting to localStorage because it "works the same and persists" is the mistake. Persistence is a liability when the state should expire.


The Hidden Behaviors

Things the spec does mention but tutorials usually do not.

Everything is a string

localStorage.setItem('x', 42) stores the string "42", not the number 42. localStorage.setItem('x', {a: 1}) stores the string "[object Object]", which is almost certainly not what you wanted. The standard workaround is JSON.stringify on the way in and JSON.parse on the way out, but:

  • JSON.parse throws on invalid JSON. Always wrap reads in try/catch or you will crash on the first corrupted entry.
  • JSON does not handle Date, Map, Set, BigInt, RegExp, Function, undefined, circular references, or class instances. Anything you store goes through a lossy serialization.
  • Numbers larger than Number.MAX_SAFE_INTEGER lose precision through the round-trip.

The string-only contract is why people reach for localForage, idb-keyval, or superjson even for cases that "could just use localStorage."

Writes block the main thread

Every setItem and getItem is synchronous. On the main thread. With disk I/O involved. The cost is small for individual operations, but in tight loops or during page load it adds up. I have seen pages with for (const item of cart) { localStorage.setItem(item.id, JSON.stringify(item)) } spend 80ms in storage writes alone, on a mid-tier laptop. That is 5 frames of a 60fps render budget.

If you have more than a handful of writes to do, batch them: write a single JSON-serialized blob, not N individual keys. Or move to IndexedDB, which is asynchronous and does not block.

The storage event has a quirk

The storage event fires on window when localStorage is modified, so other tabs/windows can react to the change. The quirk: the event does not fire in the tab that made the change. Only in other same-origin tabs. This is by design (the tab that did the write already knows), but it means if you want a uniform reactive pattern across all tabs including the originating one, you have to dispatch a separate signal manually.

function setShared(key, value) {
  localStorage.setItem(key, value);
  window.dispatchEvent(new StorageEvent('storage', {
    key, newValue: value, storageArea: localStorage,
  }));
}

This is one of the standard tricks behind cross-tab state libraries.

setItem can throw

If you exceed the quota (more on this below), setItem throws QuotaExceededError. Same goes for if storage is disabled (some browsers in private mode return a Storage object that throws on every write). Wrap your writes in try/catch or accept that one bloated value will crash the call site.

Strings are UTF-16

The quota is measured in UTF-16 code units, not bytes or characters. A "10 character" string of all-ASCII is 20 bytes against your quota. A "10 character" string with emoji or CJK can be 40+ bytes. Quota math is harder than it looks.


Quotas and Eviction

The spec says implementations may impose quotas. Browsers do.

Browser Per-origin localStorage quota Total per profile
Chrome / Edge ~10 MB ~80% of disk for the broader Storage API
Firefox ~10 MB per group (eTLD+1) similar profile-wide budget
Safari ~5 MB smaller global budget historically

The 5 MB figure that gets quoted in old tutorials is the lowest-common-denominator spec recommendation. Modern Chromium and Firefox give you about 10 MB. Safari has tightened storage limits over time as part of its privacy-driven storage policy, and on iOS you should assume your storage can be evicted aggressively, especially for sites the user has not engaged with recently.

Eviction: when the browser is under storage pressure, it evicts data. The eviction policy is browser-specific, but in general it targets origins the user has not visited recently and that have not requested "persistent" storage via the Storage API's navigator.storage.persist() call. If you depend on localStorage data sticking around, you should call navigator.storage.persist() and check the return value, because by default your storage is "best-effort" and can vanish.

Safari is the most aggressive: as of recent versions, non-persistent storage from sites the user has not interacted with for 7 days can be cleared. Chrome and Firefox are more lenient but make no guarantees.

For data you actually need to keep, the rule is: do not put it only in localStorage. Persist it server-side or accept that it may disappear without warning.


The Security Model

This is the part of the topic that most tutorials skip, and it is the most important part for anyone making real decisions about what to put in localStorage.

Same-origin JavaScript can read anything

localStorage is accessible from any JavaScript executing in the page's origin. There is no per-key permission model. There is no equivalent of an HttpOnly cookie. If you put data in localStorage, every script on your page, including third-party scripts you have included, can read it.

This is the most important property to understand: if your page is vulnerable to XSS, your localStorage is compromised. All of it. Authentication tokens, user data, anything. An XSS payload can read every key, exfiltrate every value, and write malicious values back. There is no defense at the storage layer; the only defense is preventing XSS in the first place.

This is why "store the JWT in localStorage" is a bad pattern for sensitive applications. The OWASP guidance, and the position I follow, is:

  • Authentication tokens that must survive page reloads should be in HttpOnly, Secure, SameSite=Lax (or Strict) cookies.
  • localStorage is appropriate for non-sensitive data that you would not mind seeing in a debug console: user preferences, UI state, dismissed-tooltip flags.
  • Anything more sensitive (PII, payment data, full user records) does not belong in localStorage at all. Keep it on the server and fetch it when needed.

There is no encryption

The data on disk is plaintext (well, UTF-16 plaintext). The data in the browser's process memory is plaintext. Anyone with read access to the user's profile directory can read every site's localStorage. Browser extensions with the storage or tabs permission can read it from any page they are allowed to inject into.

If you encrypt values yourself before storing them, you still have to keep the key somewhere, and the key has to be available to the page's JavaScript to decrypt. There is no scheme by which client-side encryption of localStorage meaningfully improves security against same-origin attackers. It is theatre.

No SameSite, no CORS, no Origin header

Cookies have SameSite. Network requests have CORS and the Origin header. localStorage has neither. Cross-origin scripts cannot read your localStorage (because the same-origin policy prevents the cross-origin script from executing in your origin in the first place), but same-origin scripts can do whatever they want. This is fine until you embed something. Once you have a <script src="https://cdn.thirdparty.com/widget.js">, that widget runs with your full localStorage access. If the CDN is compromised (the npm-style supply chain attack pattern), your storage is leaked.

The mitigation here is the same as for XSS: minimize untrusted third-party scripts, use Subresource Integrity (integrity="sha384-...") for the ones you must include, and use Content Security Policy to constrain what can execute.

Prototype pollution

The index-signature form (localStorage.cities = ...) inherits the same prototype-pollution surface as any other object. localStorage.constructor, localStorage.toString, localStorage.hasOwnProperty are all real properties on the Storage prototype. Setting them via the property-access form has implementation-specific behavior across browsers, and getItem may or may not see them depending on the engine. The safe form is always getItem / setItem, never the property-access form. Use the methods.

Private browsing modes

Some browsers in private/incognito mode give you a Storage object that works during the session but throws on certain operations or has a much smaller quota. Some clear it on tab close. Some used to silently fail writes. Do not assume localStorage is available; check, and fall back gracefully.


Stubs and Wrappers People Have Built on Top

Because the raw API is awkward, the ecosystem has produced layers on top of it. Worth knowing about even if you do not use them, because they tell you what the raw API is missing.

localForage (Mozilla): a Storage-shaped API backed by IndexedDB by default, falling back to WebSQL (deprecated) and then localStorage. Async (returns Promises), structured values (objects, not just strings), and a much larger quota because it sits on IDB. The right default for "I want localStorage but better."

idb-keyval (Jake Archibald): a minimal key/value wrapper around IndexedDB. Smaller and simpler than localForage, no fallback layer, just a clean async key/value API on top of IDB. Good when you know your browser support includes IDB and you do not need the fallback.

Dexie: a more substantial IndexedDB wrapper that exposes a queryable API with indexes, compound keys, and live queries. Not a localStorage replacement so much as a real client-side database.

Zustand persist middleware and redux-persist: state-management libraries that hide localStorage behind a persist flag. Read your store from storage on app boot, write to storage on every change. The "I just want my state to survive a refresh" pattern.

TanStack Query: the persistQueryClient plugin can serialize the entire query cache to storage (localStorage, IDB, or anything implementing a storage interface) so that cached server data survives reloads. Useful for offline-first apps and for instant-feeling reloads.

Replicache / RxDB: serious sync engines that use IDB as their backing store and provide reactive queries, real-time sync to a server, and conflict resolution. The right answer when "I need persistent state on the client" turns into "I need a real local-first database."

The takeaway: most apps that reach for localStorage for anything more than a single string preference end up wanting a library that hides it. The raw API's lack of async, structure, and querying becomes painful fast.


Alternatives Worth Knowing

A decision is rarely "should I use localStorage." It is "of the seven storage primitives the browser gives me, which one fits this problem." A short tour:

IndexedDB

The browser's actual database primitive. Async, transactional, supports indexes, structured values (anything structuredClone can clone), and quotas in the gigabytes (often "up to ~60% of free disk" in Chromium, with prompts for persist). The API is famously awkward (it was designed for portability, not ergonomics), which is why every library above wraps it. Use IDB when you need to store more than a few hundred KB, when you need to query by anything other than the primary key, or when you cannot block the main thread.

Cache API

Designed for caching Request/Response pairs, lives in Service Workers and is also accessible from pages. The right place to put fetched HTTP responses you want to serve offline. Not a key/value store for arbitrary data. Use it when the thing you are caching is an HTTP response.

Origin Private File System (OPFS)

A real file system accessible only to the origin, exposed via the File System Access API. Synchronous access is available inside Web Workers (getFileHandle().createSyncAccessHandle()), which means high-throughput sequential I/O without blocking the main thread. SQLite WASM uses OPFS as its backing store to give you real SQLite in the browser with disk-like performance. Use OPFS when you need real file semantics: append-only logs, large blobs, anything where you want to write incrementally rather than serialize-then-write.

Cookies

Server-aware, sent on every request to the matching origin, support HttpOnly, Secure, SameSite, Max-Age, Domain, Path. The right place for anything the server needs to see on every request, especially authentication tokens (with HttpOnly and Secure). Subject to a small per-cookie size limit (~4KB) and a per-domain cookie count limit. Newer code should reach for the CookieStore API instead of document.cookie; it is async and saner.

BroadcastChannel

new BroadcastChannel('app') opens a same-origin pubsub channel across tabs/windows. Pair it with localStorage writes to get reliable cross-tab reactivity without relying on the storage event's "fires on other tabs only" quirk.

Not technically client-side storage, but worth remembering: the server can put data in a cookie, and the browser will hand it back on every request. For things the server is going to read anyway, this is often the right answer.


A Decision Framework

When you reach for storage, the question to ask is not "localStorage or sessionStorage." It is a sequence:

  1. Does the server need to see this on every request? Use a cookie. HttpOnly + Secure + SameSite if it is sensitive.
  2. Is this an HTTP response I want to serve offline? Cache API in a Service Worker.
  3. Is this more than a few hundred KB, or do I need to query it, or do I need indexes? IndexedDB, via idb-keyval or Dexie.
  4. Is this large binary data, or do I need streaming/append semantics? OPFS, possibly with SQLite WASM on top.
  5. Is this state that should expire when the tab closes? sessionStorage, or just in-memory React/Vue/Svelte state.
  6. Is this a small string preference that should survive reloads, and would I be okay if it disappeared under storage pressure? localStorage.
  7. Is this sensitive (token, PII, payment)? Not localStorage. Not sessionStorage. Server-side, with the client holding only a cookie reference.

Most tutorials skip to step 6 and stop. Real applications need the full decision tree, and the answer is localStorage less often than you would guess from how often it gets reached for.


Ten Practical Rules

A short, opinionated checklist that captures most of the article in a form you can use on Monday morning:

  1. Treat localStorage as "preferences storage", nothing more. Theme, language, dismissed-banner flags, last-active-tab, layout choices. Things that, if they vanished, would mildly annoy the user and nothing else.
  2. Never store anything you would not paste into a public Slack channel. No tokens, no PII, no IDs that are sensitive in your domain.
  3. Always JSON.parse inside a try/catch. Storage corruption is rare but real, and one corrupted entry should not crash the page.
  4. Always handle QuotaExceededError. Even if your handling is "log it and fall back to in-memory state", do not let the write crash silently.
  5. Use getItem and setItem, not the property-access form. Avoids the prototype-pollution surface entirely.
  6. For anything more than a few keys, reach for localForage or idb-keyval. The async API and the IDB-sized quota are worth the dependency.
  7. For cross-tab reactive state, use BroadcastChannel plus a storage backend, not the storage event alone. It is more reliable and clearer to read.
  8. For multi-tab apps that mutate shared state, use the Web Locks API to serialize the writers. Two tabs racing on the same key will produce data you cannot reconstruct later.
  9. Call navigator.storage.persist() if you actually need the data to survive eviction. Otherwise, accept that it can vanish.
  10. Plan for the data going away. Browsers clear it. Users clear it. Private mode does not persist it. Profile resets nuke it. If the only copy lives in localStorage, you do not have a copy.

Wrapping Up

localStorage and sessionStorage are small, sharp, simple-looking tools that hide a surprising amount of complexity. The API surface is unintimidating. Underneath it is a browser-managed store with quotas you do not control, eviction you cannot prevent, security properties that disqualify it from carrying anything sensitive, and a synchronous I/O contract that quietly costs you frame budget on every call.

Used carefully, for the things they are good for (small string preferences, UI state that should survive reloads, tab-scoped working memory), they are excellent. They are five lines of code away. They require no setup. They are supported everywhere. For the right job they are the right tool.

Used as a general-purpose client-side database, they will hurt you. Most of the time, the question "should I use localStorage?" is the wrong question. The right question is "what is the actual shape of this data, what are its lifecycle and security requirements, and which of the seven storage primitives the browser gives me actually fits?" Walk the decision tree before you reach for setItem.

The web platform has spent the last decade quietly giving us better tools for client-side state: IndexedDB for structured data, the Cache API for HTTP responses, OPFS for real file semantics, HttpOnly cookies for authentication, Web Locks for cross-tab coordination, BroadcastChannel for cross-tab messaging. The reason localStorage is still the default answer to "where do I put this" is not that it is the right answer; it is that it is the easiest answer to type. Easy is not the same as correct.

Storage is not the API. Storage is the contract underneath the API. Read the contract.


References and Further Reading

Specifications:

MDN references:

Browser implementation notes:

Wrapper libraries:

  • localForage :- localStorage-shaped async API backed by IndexedDB with fallbacks
  • idb-keyval :- minimal IndexedDB key/value wrapper
  • Dexie :- substantial IndexedDB wrapper with indexes and live queries
  • localStorage polyfills and shims :- for environments where the native API is missing or broken

Security guidance:

Local-first and sync engines:

A new day, another opportunity to read the contract underneath the API.