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#
localStorageis shared by every tab and window on the origin, and it persists until code or the user clears it. It survives a browser restart.sessionStorageis 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 emptysessionStorage. Only duplicating a tab or a session restore clones it.
What "per tab" actually means#
| Action | localStorage | sessionStorage |
|---|---|---|
| Reload the tab | Kept | Kept |
| Navigate to another same-origin page in the tab | Kept | Kept |
| Close the tab, open it again | Kept | Gone |
| Open a second tab to the same site | Shared | Separate and empty |
| Duplicate the tab | Shared | Copied at that moment, then diverges |
| Open a link in a new tab (target=_blank), or window.open | Shared | New, empty |
| Browser restart | Kept | Gone (unless the browser restores the session) |
| Private / incognito window | Works, wiped when that private session ends | Works, 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".
window.addEventListener("storage", (e) => {
if (e.key === "app:theme") applyTheme(e.newValue)
})sessionStoragewrites are not broadcast to other tabs. Astorageevent for asessionStoragechange 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.
BroadcastChannelis 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 storing | Use | Why |
|---|---|---|
| Theme (dark / light) preference | localStorage | Wanted in every tab and on the next visit; mirror to a cookie if the server needs it |
| Locale, currency, density, other user preferences | localStorage | Same reasoning as theme |
| "Remember me" / last username hint (not the token) | localStorage | A convenience that should persist |
| Auth token | Neither, ideally | An httpOnly cookie. If forced, sessionStorage limits the blast radius, but it is still XSS-readable |
| Multi-step form / wizard draft | sessionStorage | Per tab, so two tabs are two independent drafts; clears itself |
| Scroll position, open panel, active sub-tab | sessionStorage or memory | Transient, tab-scoped, no reason to persist |
| Guest shopping cart | localStorage | Persist across sessions, but enforce your own expiry |
| "I dismissed this banner" | Your call | localStorage to never nag again; sessionStorage to nag once per session |
| Analytics session id | sessionStorage | That is literally what a session is |
| Anything secret, large, or read server-side | Not Web Storage | Use 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.
<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.
// 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; }
}function useTheme() {
const [theme, set] = useState(getTheme);
useEffect(() => watchTheme(set), []);
return { theme, setTheme: (v) => { setTheme(v); set(v); } };
}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,localStorageandsessionStorage. 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.
// @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()); }
});// React binding, still no prop drilling
import { useSyncExternalStore } from "react";
export const usePlatform = () =>
useSyncExternalStore(subscribe, getPlatform, getPlatform);Which storage for which variable
| Variable | Storage | Reason |
|---|---|---|
| theme, locale, tenant, user preferences | localStorage | Consistent across every tab and the next visit; survives a hard navigation between modules |
| "which project / workspace am I in right now" | sessionStorage | Scoped 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 on | sessionStorage | Per tab, auto-clears, no cross-tab bleed |
| a short-lived auth token | neither | httpOnly cookie |
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 syncThe 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 calllocalStorage.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:
windowandlocalStoragedo not exist on the server. Checktypeof window. - Everything is a string.
setItem("done", true)stores"true".JSON.parseon 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
storageevent only reaches the others.
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.