Chapter 25: Browser Storage as Application State
A frontend application has to put state somewhere.
The default answer most React, Vue, and Svelte applications give is in JavaScript memory. State lives in component state, in framework stores (Redux, Zustand, Jotai, Pinia, MobX), in context providers, in observables. The state is rich, reactive, and ergonomic — and the moment the user closes the tab, all of it is gone. The next time the user opens the application, the state has to be reconstructed from somewhere else. The reconstruction is usually a server fetch, a localStorage load, or a we just lost the user’s draft moment.
The browser has answers to this. They’ve been there for years. Most applications use them poorly.
This chapter argues that the browser’s storage tier — localStorage, sessionStorage, IndexedDB, the Cache API, OPFS, and the storage event for reactivity — is the platform’s real answer to where does state live?. The capabilities are unevenly known across the frontend community. The architectural pattern they support is, in Kitsune’s framing, storage is the state layer. Not a place to dump values for persistence. The substrate the application’s state machine runs on top of.
The Storage Hierarchy
Section titled “The Storage Hierarchy”Operating systems have a storage hierarchy — registers, L1 cache, L2 cache, L3 cache, RAM, disk, network. Each level is bigger, slower, and more durable than the one above. Application code rarely thinks about the hierarchy directly because the OS and the language runtime handle the mapping.
The browser has its own version of this hierarchy. The names and characteristics:
JavaScript memory. The fastest, smallest, most volatile layer. Values exist in component state, framework stores, closures, instance fields. Memory is cleared on tab close, on navigation, and on any framework remount. Most frontend code spends most of its time at this level.
sessionStorage. Synchronous key/value storage, string-only, scoped to the current browser tab. Survives page reloads. Cleared when the tab closes. Roughly 5–10 MB available, depending on browser. Useful for state that should survive a reload but shouldn’t follow the user across sessions.
localStorage. Synchronous key/value storage, string-only, scoped to the origin. Survives tab closes and browser restarts. Persists until explicitly cleared. Same 5–10 MB limit. The most-used storage API in the frontend ecosystem, often used carelessly.
IndexedDB. Asynchronous, structured storage. Stores arbitrary JavaScript objects (not just strings). Supports indexes for efficient querying. Supports transactions for atomic operations. Quotas measured in tens or hundreds of megabytes, sometimes larger. The serious application database, sitting in the browser, available since 2012 but historically underused because the API is verbose.
The Cache API. Storage for Request/Response pairs, designed for service workers but usable outside them. Stores entire HTTP responses keyed by request. Useful for offline-first applications and for storing fetched data with the same shape it came in.
OPFS (Origin Private File System). The newest addition (2022–2023, generally available). A hidden file system scoped to the origin, accessible through file-handle APIs. Optimized for high-performance access. Used by applications that need real file-like storage (SQLite-in-the-browser implementations like sql.js, document editors, video editors).
Cookies. Small, key/value, sent with every HTTP request to the origin. Useful for state the server cares about (sessions, authentication). Mostly outside this chapter’s scope because cookies are a server-cooperation mechanism more than a client-side state layer.
That’s six tiers, available in every major browser, totaling capabilities that would have been a startup business in 2008. The frontend ecosystem treats most of them as edge-case tools. The architectural argument of this chapter is that they’re not edge cases. They’re the substrate.
localStorage and sessionStorage: The Easy Case
Section titled “localStorage and sessionStorage: The Easy Case”The simplest case is localStorage. The API is famously minimal:
localStorage.setItem('theme', 'dark')localStorage.getItem('theme') // 'dark'localStorage.removeItem('theme')localStorage.clear()The first thing to notice: values are strings. If you store an object, you have to serialize it first:
localStorage.setItem('user', JSON.stringify({ name: 'Jeremy', id: 'me' }))const user = JSON.parse(localStorage.getItem('user'))The second thing to notice: the API is synchronous. Every read and write blocks the main thread. For small values this is fine; for large values it’s not. A localStorage.setItem of a 2 MB string can stall the page noticeably.
The third thing to notice: storage is scoped to the origin (the protocol + host + port). All tabs of example.com share the same localStorage. A page from evil.example.com has a different localStorage. A page from example.com:443 (HTTPS default) has a different localStorage than example.com:8080. This origin scoping is the platform’s primary security boundary.
sessionStorage has the same API and the same constraints, with one difference: scope is per-tab, not per-origin. A user with two tabs of the same site open has two independent sessionStorage instances. Closing the tab clears the storage.
These APIs are useful for what they are: small, simple, blocking storage of string values. They’re widely understood and widely supported (every browser back to IE8). The over-use pattern is treating them as a serialization layer for the framework’s state store — JSON.stringify(reduxState) on every state change, with the page recovering its state on next load. This works for small applications and degrades quickly as state grows.
IndexedDB: The Real Database
Section titled “IndexedDB: The Real Database”IndexedDB is the platform’s serious storage API. It’s a transactional, indexed, asynchronous database, with the kind of capabilities a backend developer would expect from any database.
The API is, famously, verbose. Opening a database, creating object stores, declaring indexes, and running transactions takes more code than localStorage.setItem. The verbose API is one of the reasons IndexedDB has been underused — the developer ergonomics are difficult enough that many teams reach for localStorage plus JSON serialization even when IndexedDB would be the right choice.
Several wrapper libraries make the API more pleasant. idb (Jake Archibald’s tiny Promise-based wrapper) is the most widely used. Dexie.js (David Fahlander) provides a higher-level query API. localForage (Mozilla) provides a localStorage-style API backed by IndexedDB. Each of these libraries earns its place — IndexedDB raw is unpleasant; the libraries make it usable. Most modern applications that use IndexedDB use it through one of them.
What IndexedDB enables, in architectural terms:
Large structured storage. Tens of megabytes is comfortable; hundreds of megabytes is possible. A typical application can store its entire offline dataset — products, users, settings, draft content — in IndexedDB without running into quota issues.
Indexed queries. The database can maintain indexes on specific properties, so a query like all orders for user X runs efficiently even with thousands of orders stored. The application doesn’t have to load everything into memory and filter.
Transactions. A group of operations either all succeed or all fail. The same atomicity guarantee a backend developer would expect from PostgreSQL or SQLite, scaled to the browser.
Asynchronous access. No main-thread blocking. The application’s UI stays responsive while the database does work.
Cross-tab consistency. Two tabs of the same origin see the same IndexedDB. A write in one tab is visible to the other (with appropriate coordination, which we’ll come to).
The use cases this enables are significant. Offline-first applications (the user can keep working without a network). Local-first applications (the user’s data lives in the browser and syncs to the server, rather than the other way around). Heavy client-side editing (rich text, code, design files) where every keystroke shouldn’t round-trip to a server. Long-running drafts that survive across sessions.
The Notion desktop client, Linear, Figma, Obsidian, and most modern productivity applications use IndexedDB heavily. The pattern is mature; the libraries are stable; the browser support is universal. The barrier to adoption is mostly cultural — the frontend community’s default state-management story is still framework stores plus a token localStorage hook, when IndexedDB would handle the same use cases at much larger scale.
OPFS and the Cache API
Section titled “OPFS and the Cache API”Two more storage layers are worth knowing about.
OPFS (Origin Private File System) is the most recent storage addition. It provides a file-system API scoped to the origin, with the high performance characteristics native file systems have. The use case is workloads that need file-like access patterns — streaming reads and writes, large binary blobs, files-on-disk semantics — at a performance level that IndexedDB can’t quite match.
The most prominent use of OPFS so far is SQLite-in-the-browser. Projects like sql.js and the official WASM SQLite build (@sqlite.org/sqlite-wasm) can use OPFS as their storage backend, giving applications a full SQLite database running entirely in the browser, with file-level persistence and SQL query capabilities. The combination is powerful enough that several local-first applications now ship SQLite-on-OPFS as their entire data layer.
OPFS is reliable across browsers as of 2024. Adoption is still early. The applications that benefit most are the ones with file-like or database-like workloads that IndexedDB handles awkwardly.
The Cache API is designed for service workers but usable outside them. The API stores entire HTTP Request/Response pairs, keyed by the request URL (and optionally headers). The typical use case is service-worker caching for offline support — when a request goes out, the service worker checks the cache, returns the cached response if present, falls back to the network if not.
The Cache API also has uses outside service workers. An application that fetches large amounts of read-only data (product catalogs, reference content, static assets) can store the responses directly and re-use them across sessions. The storage format is the natural one for HTTP responses, so no serialization layer is required.
For application architecture, the Cache API is most useful when the data being cached has the shape of something the application fetched. For data with a different shape (user-edited content, application-state objects, transactional records), IndexedDB or OPFS is usually a better fit.
BroadcastChannel: Cross-Tab Communication
Section titled “BroadcastChannel: Cross-Tab Communication”A separate but related capability worth naming: cross-tab communication.
A user with two tabs of the same application open is using the same origin’s storage across both tabs. If the user updates a setting in one tab, the other tab’s UI doesn’t automatically know. Application state in one tab’s JavaScript memory is invisible to the other tab.
The platform’s answer is BroadcastChannel:
// Tab A:const channel = new BroadcastChannel('app-events')channel.postMessage({ type: 'settings.updated', userId: 'me' })
// Tab B (also has a BroadcastChannel of the same name):channel.addEventListener('message', (event) => { if (event.data.type === 'settings.updated') { // refresh local view }})Both tabs subscribe to a named channel. Either tab can post a message; the other receives it. The pattern is structurally a pub/sub bus, scoped to the origin, with the browser handling the cross-tab plumbing.
This is, in Kitsune’s framing, what makes storage is the state layer viable as an architectural pattern. The application writes a setting to localStorage or IndexedDB. A BroadcastChannel message announces the change. All open tabs hear the announcement and refresh their state from the canonical storage. The storage is the source of truth; the channel is the cache-invalidation signal.
The Storage Event: Reactivity for Free
Section titled “The Storage Event: Reactivity for Free”There’s a second mechanism for cross-tab reactivity that’s older and more limited but still useful.
When a value in localStorage changes, the browser fires a storage event on every other tab of the same origin. (Notably, not on the tab that made the change — only other tabs.) The event includes the key that changed, the new value, and the old value.
window.addEventListener('storage', (event) => { if (event.key === 'theme') { applyTheme(event.newValue) }})This is, structurally, a built-in reactivity mechanism for localStorage-backed state. The application doesn’t have to set up its own pub/sub for cross-tab synchronization of localStorage values; the platform fires the event automatically.
Combined with custom event wrappers (so the same tab also sees a synthetic event when it writes a value), this becomes a complete reactivity layer for a localStorage-backed state store:
function setStorageValue(key, value) { localStorage.setItem(key, JSON.stringify(value)) window.dispatchEvent(new CustomEvent('storage-sync', { detail: { key, value } }))}
function subscribeStorage(key, callback) { // Same-tab: listen for our custom event window.addEventListener('storage-sync', (event) => { if (event.detail.key === key) callback(event.detail.value) }) // Cross-tab: listen for the native storage event window.addEventListener('storage', (event) => { if (event.key === key) callback(JSON.parse(event.newValue)) })}A few dozen lines like this give the application a reactive localStorage layer that works in the same tab and across all tabs. Combine it with Lit’s @property decorator (for reactive custom-element properties), and you have a complete state layer that uses storage as the source of truth, fires platform events for changes, and propagates changes across the application’s UI automatically.
This pattern is, in many applications, a complete replacement for client-side state libraries. Zustand, Jotai, Redux, MobX — each of these is a JavaScript-memory-based state store. For state that should persist beyond the page lifetime, storage plus the storage event plus a small wrapper does the same job without a dependency.
Storage Is the State Layer
Section titled “Storage Is the State Layer”The architectural argument the chapter has been building toward is straightforward.
Storage is the state layer. Not a backup for state that primarily lives in memory. The primary location. JavaScript memory becomes a cache of values read from storage; writes go to storage first, then propagate through the application via storage events; cross-tab consistency is handled by the platform.
The shape of an application built this way:
The storage layer is the source of truth. localStorage, IndexedDB, OPFS, or some combination. Each piece of state has a stable key and a defined schema.
Reads happen through small wrapper functions that pull from storage and (for IndexedDB or OPFS) return promises. The application’s components read state through these wrappers.
Writes happen through small wrapper functions that update storage, then fire a synthetic event (so the same tab sees the change immediately) in addition to the storage event the browser fires for other tabs.
Components subscribe to specific keys. When a key changes, the relevant components re-render or update. Lit’s reactive properties make this ergonomic — the component declares a property bound to a storage key and updates automatically when the storage changes.
Cross-tab consistency comes free, because the storage is shared and the events fire automatically.
For state that needs to be richly reactive within a single component (form input values, transient UI state — the open/closed state of a menu), JavaScript memory or local component state is still the right tool. The argument isn’t that storage replaces all memory; it’s that durable application state should live in storage, with memory as a cache layer above it.
What This Displaces
Section titled “What This Displaces”This is the part of the argument that requires some honesty.
A substantial fraction of the client-side state-management ecosystem — Redux, Zustand, Jotai, Recoil, Pinia, MobX, Valtio, and many others — is solving a problem the platform now mostly solves. Each of these libraries gives the application a reactive store with subscribed components. Each handles middleware, derived state, async actions, and the patterns that grow up around state management.
For state that needs to persist beyond the page lifetime — the kind of state most production applications care about — the platform’s storage tier handles persistence directly. The application can build a small reactive wrapper around localStorage or IndexedDB and have functional equivalents to most of what the state libraries provide, with the additional property that the state is durable, cross-tab consistent, and offline-resilient.
This doesn’t mean every application should ditch its state library. Mature codebases that already depend on Redux or Zustand have a real cost to migrating, and the libraries’ ergonomics are sometimes better than the platform’s APIs even for simple cases. The argument is that for new applications, or for the durable state portion of existing applications, the platform-first answer is workable.
Kitsune’s design takes this position directly. The architecture’s state layer is the browser’s storage tier. Modules read from storage. Modules write to storage. Components observe storage through Lit’s reactive properties wired to storage events. The runtime doesn’t include a separate store. The store is the platform.
What Comes Next
Section titled “What Comes Next”The next chapter takes a related architectural position on routing. The previous SPA generation pulled routing into the client (Backbone Router, AngularJS routes, React Router, the meta-framework routers of Chapter 15). Kitsune’s position is that routing belongs to the server — URL changes mean new server requests, and the browser owns navigation. The chapter argues this position seriously and engages with the SPA-router inheritance every reader brings.
Exercise: Storage as State
Section titled “Exercise: Storage as State”Build a small theme-switcher application.
The requirements: the user can choose between Light, Dark, and System themes. The choice is reflected in the UI immediately. The choice persists across page reloads. The choice synchronizes across multiple tabs of the same origin.
Build it without any framework state store. Use only:
- A
<select>for the theme choice. localStorage.setItem('theme', value)on change.- A function
applyTheme(theme)that sets adata-themeattribute on<html>and lets CSS handle the rest. - A
storageevent listener that callsapplyThemewhen another tab changes the value. - A synthetic
CustomEventfired alongside thesetItemcall so the current tab also reacts.
Open the page in two tabs. Change the theme in one. Watch the other update.
Then reflect on:
- How many lines of JavaScript did this take? (Probably under 30.)
- Where did the state live? (In
localStorage.) - How would this look with Redux, Zustand, or a similar library? Would it persist across reloads without additional configuration?
- If you wanted the theme to apply across pages (not just one), what would change? (Nothing —
localStorageis shared across the origin.) - What if you wanted the theme to not persist — to reset on tab close? (Use
sessionStorageinstead oflocalStorage.) - What if the state were larger — a draft of a long document? (Use
IndexedDB, with a small wrapper likeidbto make the API tolerable.)
The point is to feel the architectural pattern. The platform handles persistence, cross-tab consistency, and reactivity. The application provides the schema, the read/write wrappers, and the UI binding. Most of the state-management tooling in the frontend ecosystem is solving problems the platform now solves directly.