Portfolio/Writing/sessionStorage vs localStorage: themes, drafts, tabs, and module-federated apps

sessionStorage vs localStorage: themes, drafts, tabs, and module-federated apps

How the two Web Storage APIs really differ (tab scope, lifetime, the storage event), a scenario table for picking one, why theme preference belongs in localStorage, and how to keep a consistent theme and shared variables across a module-federated app without prop drilling.

localStorage and sessionStorage share an API, a 5 MB-ish budget, an origin-scoped namespace, and the fact that both are synchronous and store strings only. The difference is scope and lifetime, and getting that wrong is how you end up with a theme that resets in the second tab, or a wizard draft that leaks between two things the user is doing at once.

The difference in one paragraph#

  • localStorage is shared by every tab and window on the origin, and it persists until code or the user clears it. It survives a browser restart.
  • sessionStorage is scoped to one tab (one top-level browsing context). It survives reloads and same-tab navigations, and it is wiped when that tab closes. A brand-new tab starts with an empty sessionStorage. Only duplicating a tab or a session restore clones it.

What "per tab" actually means#

ActionlocalStoragesessionStorage
Reload the tabKeptKept
Navigate to another same-origin page in the tabKeptKept
Close the tab, open it againKeptGone
Open a second tab to the same siteSharedSeparate and empty
Duplicate the tabSharedCopied at that moment, then diverges
Open a link in a new tab (target=_blank), or window.openSharedNew, empty
Browser restartKeptGone (unless the browser restores the session)
Private / incognito windowWorks, wiped when that private session endsWorks, wiped when the tab or private session ends

Same-origin iframes inside a tab share that tab's sessionStorageand the origin's localStorage. A cross-origin iframe gets its own partitioned storage, and modern browsers partition it further by the top-level site, so you cannot use storage to talk across an origin boundary. That matters later for module federation.

The storage event, for cross-tab sync#

When localStorage changes, the browser fires a storage event on every other tab and window of that origin. Not the tab that made the change. The event carries key, oldValue, newValue, url and storageArea. This is the built-in mechanism for "change the theme in one tab, every tab follows".

js
window.addEventListener("storage", (e) => {
  if (e.key === "app:theme") applyTheme(e.newValue)
})
  • sessionStorage writes are not broadcast to other tabs. A storage event for a sessionStorage change only reaches other same-origin frames in the same tab.
  • The event does not fire in the tab that made the write, so update your own tab directly after writing.
  • BroadcastChannel is the alternative when you want to message other tabs without going through storage. It also does not cross origins.

Picking one, by scenario#

What you are storingUseWhy
Theme (dark / light) preferencelocalStorageWanted in every tab and on the next visit; mirror to a cookie if the server needs it
Locale, currency, density, other user preferenceslocalStorageSame reasoning as theme
"Remember me" / last username hint (not the token)localStorageA convenience that should persist
Auth tokenNeither, ideallyAn httpOnly cookie. If forced, sessionStorage limits the blast radius, but it is still XSS-readable
Multi-step form / wizard draftsessionStoragePer tab, so two tabs are two independent drafts; clears itself
Scroll position, open panel, active sub-tabsessionStorage or memoryTransient, tab-scoped, no reason to persist
Guest shopping cartlocalStoragePersist across sessions, but enforce your own expiry
"I dismissed this banner"Your calllocalStorage to never nag again; sessionStorage to nag once per session
Analytics session idsessionStorageThat is literally what a session is
Anything secret, large, or read server-sideNot Web StorageUse a cookie with flags, or the server

Theme, specifically: the flash-of-wrong-theme problem#

React state alone forgets the choice on reload and flashes the default first. localStorage remembers it, but your JS reads it after the first paint, so you still get a flash. The fix is a tiny inline script in <head> that runs before the browser paints.

html
<script>
  (function () {
    try {
      var k = "app:theme", v = localStorage.getItem(k);
      if (v !== "light" && v !== "dark") {
        v = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
      }
      document.documentElement.dataset.theme = v;
    } catch (e) {}
  })();
</script>

Then one module owns the preference and everything else reads from it.

js
// theme.js: one source of truth
const KEY = "app:theme";
const media = window.matchMedia("(prefers-color-scheme: dark)");

export function getTheme() {
  try {
    const stored = localStorage.getItem(KEY);
    if (stored === "light" || stored === "dark") return stored;
  } catch {}
  return media.matches ? "dark" : "light"; // fall back to the OS setting
}

export function setTheme(value) {
  try { localStorage.setItem(KEY, value); } catch {}
  document.documentElement.dataset.theme = value; // update this tab now
}

export function watchTheme(onChange) {
  const onStorage = (e) => { if (e.key === KEY) onChange(getTheme()); };
  const onMedia = () => { if (!hasStoredTheme()) onChange(getTheme()); };
  window.addEventListener("storage", onStorage);      // other tabs
  media.addEventListener("change", onMedia);          // OS setting, if unset
  return () => {
    window.removeEventListener("storage", onStorage);
    media.removeEventListener("change", onMedia);
  };
}

function hasStoredTheme() {
  try { return localStorage.getItem(KEY) != null; } catch { return false; }
}
jsx
function useTheme() {
  const [theme, set] = useState(getTheme);
  useEffect(() => watchTheme(set), []);
  return { theme, setTheme: (v) => { setTheme(v); set(v); } };
}
If you server-render
The server cannot read localStorage. Mirror the preference into a cookie when you write it, read the cookie on the server to render the right theme, and keep localStorage as the client source of truth. This site uses the inline-script version, because it is a static SPA with no server to ask.

Module federation: one theme across modules, no prop drilling#

First, clear up what "navigate from one module to another" means, because the answer depends on it.

  • Client-side route change inside one host document (the usual case). Remote code runs in the host page, so it already shares the host's window, localStorage and sessionStorage. Nothing is lost on navigation. In-memory state would survive too, if it lived in a shared singleton.
  • Hard navigation between separately deployed apps (a full page load). Memory is wiped. Whatever the new page needs at bootstrap has to come from storage or a cookie.

The cleanest option: a shared platform context from the host

Put a PlatformProvider in the host and export a usePlatform() hook from a shared package that every remote imports. No prop drilling, no storage needed for propagation. This only works if React is a shared singleton across host and remotes, so context identity is the same everywhere. That singleton requirement is covered in the Module Federation post. Storage is still involved, but only as the persistence layer underneath the provider.

When you cannot rely on a shared React singleton

Use a framework-agnostic store in a shared package. A tiny event emitter, or a shared Zustand store, that every remote subscribes to. Persist it to localStorage, hydrate from it on load, and listen for the storage event so changes made in other tabs are picked up.

ts
// @acme/platform: a shared store every module imports by name
type PlatformState = { theme: "light" | "dark"; locale: string; tenantId: string };

const KEY = "mf:platform";
const listeners = new Set<() => void>();
let state: PlatformState = load();

function load(): PlatformState {
  const defaults: PlatformState = { theme: "light", locale: "en", tenantId: "" };
  try {
    return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || "{}") };
  } catch {
    return defaults;
  }
}

export function getPlatform() { return state; }

export function setPlatform(patch: Partial<PlatformState>) {
  state = { ...state, ...patch };
  try { localStorage.setItem(KEY, JSON.stringify(state)); } catch {}
  listeners.forEach((l) => l());
}

export function subscribe(l: () => void) {
  listeners.add(l);
  return () => listeners.delete(l);
}

// changes made in other tabs
window.addEventListener("storage", (e) => {
  if (e.key === KEY) { state = load(); listeners.forEach((l) => l()); }
});
ts
// React binding, still no prop drilling
import { useSyncExternalStore } from "react";
export const usePlatform = () =>
  useSyncExternalStore(subscribe, getPlatform, getPlatform);

Which storage for which variable

VariableStorageReason
theme, locale, tenant, user preferenceslocalStorageConsistent across every tab and the next visit; survives a hard navigation between modules
"which project / workspace am I in right now"sessionStorageScoped to this tab’s work session; survives reloads and hard navigations between modules, but a second tab can be a different project
unsaved draft in the module I am onsessionStoragePer tab, auto-clears, no cross-tab bleed
a short-lived auth tokenneitherhttpOnly cookie
text
  tab (one origin, one host document)
  +--------------------------------------------------+
  |  host shell                                      |
  |    PlatformProvider  /  @acme/platform store     |
  |        ^          ^          ^                    |
  |     remote A    remote B   remote C   (usePlatform)
  +---------------------|----------------------------+
                        v  persist / hydrate
                   localStorage["mf:platform"]
                        ^
                        |  storage event
                   other tabs stay in sync
Same tab, one host document. Remotes read the shared store; localStorage keeps it consistent across tabs and across hard navigations.

The exception: remotes as cross-origin iframes

If your remotes load as iframes from a different origin, storage is partitioned and they cannot see the host's localStorage. The shell owns the theme and pushes it: postMessage the current value to each iframe on load and on every change, and have each iframe ask for the current value when it mounts. BroadcastChannel does not cross origins either, so postMessage is the tool.

Guardrails for any Web Storage use#

  • Namespace every key (app:theme, mf:platform) so features do not collide, and never call localStorage.clear(), which wipes other features' keys too. Remove your own keys.
  • Wrap every read and write in try/catch. Safari private mode throws on setItem, and storage can be disabled entirely.
  • Guard for SSR: window and localStorage do not exist on the server. Check typeof window.
  • Everything is a string. setItem("done", true) stores "true". JSON.parse on read, in a try/catch, because a corrupted value will throw.
  • Writes hit disk synchronously. Debounce anything tied to typing or scrolling.
  • There is no expiry. If you need a TTL, store a timestamp and check it on read.
  • The quota is per origin and shared across all keys. A write can throw QuotaExceededError. Keep values small.
  • After writing, update your own tab directly. The storage event only reaches the others.
Summary
localStorage is origin-wide and permanent; sessionStorage is one tab and dies with it, though it survives reloads and same-tab navigations. Put theme and user preferences in localStorage and remove the flash with an inline <head> script; sync tabs with the storage event. In a module-federated app, share theme and other platform variables through a host-provided context or a shared store, not props, and back it with localStorage for consistency across tabs and hard navigations. Use sessionStorage for variables scoped to the current tab's work, like which workspace the user is in or an unsaved draft. Wrap every access in try/catch and namespace your keys.

Next up
Integrating agents into your workflow: workflow docs, skills, evals, and who orchestrates

How to turn ad-hoc agent prompting into repeatable practice: writing a workflow as a markdown document, composing it from skills, adding evals that check the workflow was actually followed, and running an orchestrator that dispatches steps and tracks the checklist.

Read next →