Storage
The browser admits substantial client-side storage mechanisms — each with substantial different scope, capacity, persistence, and semantics. The principal options: localStorage (key/value strings, ~5MB, persistent), sessionStorage (same shape, cleared on tab close), cookies (~4KB, sent with every request), IndexedDB (substantial NoSQL store, ~50MB+, async), Cache API (Request/Response pairs, for service workers), File System Access (real-file access, modern Chromium). The conventional contemporary patterns: localStorage for substantial small persistent data, IndexedDB for substantial larger structured data, cookies for substantial server-side state, the Cache API for substantial offline. The combination — substantial mechanism choice for substantial use case, the substantial async pattern of IndexedDB, the substantial integration of cookies with HTTP — is the substance of client-side storage.
localStorage
The principal small-data persistent store:
// Set:
localStorage.setItem("theme", "dark");
localStorage.theme = "dark"; // shorthand
localStorage["theme"] = "dark"; // bracket
// Get:
const theme = localStorage.getItem("theme");
const theme = localStorage.theme;
// Remove:
localStorage.removeItem("theme");
delete localStorage.theme;
// Clear all:
localStorage.clear();
// Iterate:
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
console.log(key, value);
}
for (const key of Object.keys(localStorage)) {
console.log(key, localStorage[key]);
}
The principal facts:
- Per origin (scheme + host + port).
- Persistent — survives tab/browser close.
- Synchronous — blocks the main thread.
- Strings only — JSON-serialise structures.
- ~5–10 MB per origin (browser-dependent).
- Cleared by user via browser settings; not by server.
JSON pattern
function setObject(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function getObject(key, fallback = null) {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch {
return fallback;
}
}
setObject("user", { name: "Alice", id: 42 });
const user = getObject("user");
Storage event
When localStorage changes in another tab, a storage event fires:
window.addEventListener("storage", (e) => {
console.log(e.key); // changed key
console.log(e.oldValue);
console.log(e.newValue);
console.log(e.url);
console.log(e.storageArea); // localStorage or sessionStorage
});
The event does not fire in the tab that made the change — admit substantial cross-tab synchronisation.
localStorage quota
async function checkQuota() {
const estimate = await navigator.storage.estimate();
console.log("usage:", estimate.usage);
console.log("quota:", estimate.quota);
console.log("usage detail:", estimate.usageDetails);
}
sessionStorage
Same API as localStorage; cleared on tab close:
sessionStorage.setItem("draft", text);
const draft = sessionStorage.getItem("draft");
The principal facts:
- Per tab — even multiple tabs of same origin do not share.
- Cleared on tab close — cleared on browser close.
- Persists across page navigation within the tab.
The conventional uses: substantial form drafts, substantial wizard state, substantial session-only flags.
Cookies
Strings sent with every same-origin (and configured cross-origin) HTTP request:
// Read:
document.cookie; // "theme=dark; user=alice"
// Set:
document.cookie = "theme=dark";
document.cookie = "theme=dark; max-age=31536000"; // 1 year
document.cookie = "theme=dark; expires=Fri, 31 Dec 2027 23:59:59 GMT";
document.cookie = "theme=dark; path=/; secure; samesite=strict; httponly=false";
// Delete:
document.cookie = "theme=; max-age=0";
The principal options:
| Description | |
|---|---|
max-age=N | Lifetime in seconds; 0 deletes |
expires=DATE | Lifetime as date; superseded by max-age |
path=/ | URL path scope |
domain=.example.com | Cross-subdomain |
secure | HTTPS-only |
httponly | Not accessible from JavaScript (set by server) |
samesite=strict / lax / none | Cross-origin behaviour |
The principal facts:
- Sent with every request — affect every page load; keep small.
- ~4 KB per cookie; ~50 cookies per origin.
- Substantial security implications —
httponly,secure,samesitematter. - Server-readable — substantial for substantial authentication.
Parsing pattern
function getCookie(name) {
const match = document.cookie.match(
new RegExp(`(^|; )${name}=([^;]*)`)
);
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, options = {}) {
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge != null) parts.push(`max-age=${options.maxAge}`);
if (options.path) parts.push(`path=${options.path}`);
if (options.domain) parts.push(`domain=${options.domain}`);
if (options.secure) parts.push("secure");
if (options.sameSite) parts.push(`samesite=${options.sameSite}`);
document.cookie = parts.join("; ");
}
setCookie("theme", "dark", { maxAge: 86400, path: "/", sameSite: "Lax" });
The conventional contemporary discipline — server sets cookies; JavaScript reads when needed; httponly cookies (auth tokens) are not JavaScript-accessible.
Cookie Store API
The modern API (Chromium, partial elsewhere):
// Get:
const cookie = await cookieStore.get("theme");
console.log(cookie?.value);
// All:
const all = await cookieStore.getAll();
// Set:
await cookieStore.set({
name: "theme",
value: "dark",
expires: Date.now() + 31536000000,
path: "/",
sameSite: "lax"
});
// Delete:
await cookieStore.delete("theme");
// Listen:
cookieStore.addEventListener("change", (e) => {
console.log(e.changed); // [{ name, value, ... }]
console.log(e.deleted);
});
IndexedDB
Substantial structured async store:
const request = indexedDB.open("MyDatabase", 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
const store = db.createObjectStore("users", { keyPath: "id" });
store.createIndex("email", "email", { unique: true });
};
request.onsuccess = (e) => {
const db = e.target.result;
// Read:
const tx = db.transaction("users", "readonly");
const store = tx.objectStore("users");
const getReq = store.get(42);
getReq.onsuccess = () => console.log(getReq.result);
// Write:
const wtx = db.transaction("users", "readwrite");
const wstore = wtx.objectStore("users");
wstore.put({ id: 42, name: "Alice", email: "a@b.c" });
wtx.oncomplete = () => console.log("saved");
};
request.onerror = (e) => console.error(e.target.error);
The native API is callback-based and cumbersome. The conventional contemporary discipline uses a wrapper:
idb library (or hand-rolled Promise wrapper)
import { openDB } from "idb"; // npm: idb
const db = await openDB("MyDatabase", 1, {
upgrade(db) {
const store = db.createObjectStore("users", { keyPath: "id" });
store.createIndex("email", "email", { unique: true });
}
});
// Get:
const user = await db.get("users", 42);
// Put:
await db.put("users", { id: 42, name: "Alice", email: "a@b.c" });
// Delete:
await db.delete("users", 42);
// All:
const all = await db.getAll("users");
// By index:
const user = await db.getFromIndex("users", "email", "a@b.c");
// Transaction:
const tx = db.transaction("users", "readwrite");
await Promise.all([
tx.store.put({ id: 1, name: "A" }),
tx.store.put({ id: 2, name: "B" }),
tx.done
]);
The principal facts:
- Substantial larger than localStorage — gigabytes typical.
- Async — does not block.
- Structured data — objects directly.
- Transactions — atomic groups.
- Indices — fast lookup by non-key fields.
- Versioning —
onupgradeneededruns schema migrations.
Cache API
For Service Worker offline (and direct use):
const cache = await caches.open("v1");
await cache.add("/index.html");
await cache.addAll(["/index.html", "/main.js", "/main.css"]);
const r = await cache.match("/index.html");
if (r) console.log(await r.text());
await cache.put("/data", new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" }
}));
await cache.delete("/data");
// All caches:
const names = await caches.keys();
for (const name of names) {
if (name !== "v1") {
await caches.delete(name);
}
}
In a service worker:
// service-worker.js
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("v1").then(cache =>
cache.addAll(["/", "/main.js", "/main.css"])
)
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then(response => response || fetch(event.request))
);
});
URL parameters
Often a substantial “storage” — admits state in the URL:
// Read:
const params = new URLSearchParams(window.location.search);
const filter = params.get("filter");
const sort = params.get("sort");
// Update:
const url = new URL(window.location);
url.searchParams.set("filter", "active");
url.searchParams.set("sort", "name");
window.history.pushState({}, "", url);
// React to back/forward:
window.addEventListener("popstate", () => {
const params = new URLSearchParams(window.location.search);
applyFilter(params.get("filter"));
});
The pattern admits substantial sharing (URL is the state) and substantial back/forward navigation.
Hash fragment
// Read:
window.location.hash; // "#section-2"
// Update:
window.location.hash = "section-2";
// Listen:
window.addEventListener("hashchange", () => {
const id = window.location.hash.slice(1);
document.getElementById(id)?.scrollIntoView();
});
File System Access (modern)
For substantial real file access (Chromium):
// Open file:
const [fileHandle] = await window.showOpenFilePicker({
types: [{
description: "Text files",
accept: { "text/plain": [".txt"] }
}]
});
const file = await fileHandle.getFile();
const text = await file.text();
// Save:
const handle = await window.showSaveFilePicker({
suggestedName: "output.txt",
types: [{ description: "Text", accept: { "text/plain": [".txt"] } }]
});
const writable = await handle.createWritable();
await writable.write("Hello, world!");
await writable.close();
// Directory:
const dirHandle = await window.showDirectoryPicker();
for await (const entry of dirHandle.values()) {
console.log(entry.kind, entry.name);
}
For origin private file system (no picker, persistent):
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("data.json", { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data));
await writable.close();
const file = await fileHandle.getFile();
const text = await file.text();
BroadcastChannel
For substantial cross-tab messaging:
const channel = new BroadcastChannel("app");
channel.addEventListener("message", (e) => {
console.log("got:", e.data);
});
channel.postMessage({ type: "user-logged-out" });
// Close:
channel.close();
Storage events comparison
| API | Capacity | Persistence | Sync? | Cross-tab |
|---|---|---|---|---|
localStorage | ~5–10 MB | Until cleared | Yes (blocking) | Yes (storage event) |
sessionStorage | ~5–10 MB | Tab lifetime | Yes (blocking) | No |
| Cookies | ~4 KB each | Configurable | Yes | Yes |
IndexedDB | GBs | Until cleared | No | Via BroadcastChannel |
Cache API | GBs | Until cleared | No | Yes |
Common patterns
Theme persistence
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
localStorage.setItem("theme", theme);
}
const saved = localStorage.getItem("theme");
const initial = saved ?? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
applyTheme(initial);
document.querySelector("#theme-toggle").addEventListener("click", () => {
const current = document.documentElement.dataset.theme;
applyTheme(current === "dark" ? "light" : "dark");
});
// Cross-tab sync:
window.addEventListener("storage", (e) => {
if (e.key === "theme" && e.newValue) applyTheme(e.newValue);
});
Form draft autosave
const form = document.querySelector("form");
const KEY = `form-draft-${form.id}`;
// Restore:
const saved = JSON.parse(sessionStorage.getItem(KEY) ?? "{}");
for (const [name, value] of Object.entries(saved)) {
if (form.elements[name]) form.elements[name].value = value;
}
// Save:
form.addEventListener("input", debounce(() => {
const data = Object.fromEntries(new FormData(form));
sessionStorage.setItem(KEY, JSON.stringify(data));
}, 500));
// Clear on submit:
form.addEventListener("submit", () => {
sessionStorage.removeItem(KEY);
});
IndexedDB cache for API
import { openDB } from "idb";
const db = await openDB("api-cache", 1, {
upgrade(db) {
db.createObjectStore("responses", { keyPath: "url" });
}
});
async function fetchCached(url, ttlMs = 3600000) {
const cached = await db.get("responses", url);
if (cached && Date.now() - cached.fetchedAt < ttlMs) {
return cached.body;
}
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const body = await r.json();
await db.put("responses", { url, body, fetchedAt: Date.now() });
return body;
}
Cross-tab logout
const channel = new BroadcastChannel("auth");
channel.addEventListener("message", (e) => {
if (e.data.type === "logout") {
// Clear local state, redirect
location.href = "/login";
}
});
function logout() {
localStorage.removeItem("token");
channel.postMessage({ type: "logout" });
location.href = "/login";
}
Quota management
async function persistStorage() {
if (navigator.storage?.persist) {
const isPersisted = await navigator.storage.persist();
console.log("persisted:", isPersisted);
}
}
async function showUsage() {
const { usage, quota } = await navigator.storage.estimate();
const pct = (usage / quota) * 100;
console.log(`${(usage / 1024 / 1024).toFixed(2)} MB used (${pct.toFixed(1)}%)`);
}
Bound state to query string
class QueryState {
constructor(defaults = {}) {
this.defaults = defaults;
}
get(key) {
const params = new URLSearchParams(location.search);
return params.get(key) ?? this.defaults[key];
}
set(key, value) {
const url = new URL(location);
if (value == null || value === this.defaults[key]) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
history.replaceState({}, "", url);
}
}
const state = new QueryState({ filter: "all", sort: "date" });
state.set("filter", "active");
const f = state.get("filter");
Versioned localStorage
const VERSION = 2;
const stored = JSON.parse(localStorage.getItem("data") ?? "null");
if (!stored || stored.version !== VERSION) {
// Migrate or reset:
if (stored?.version === 1) {
const migrated = migrate(stored);
localStorage.setItem("data", JSON.stringify({ version: VERSION, ...migrated }));
} else {
localStorage.removeItem("data");
}
}
localStorage size estimate
function localStorageSize() {
let total = 0;
for (const key in localStorage) {
if (Object.hasOwn(localStorage, key)) {
total += (key.length + (localStorage[key]?.length ?? 0)) * 2; // UTF-16
}
}
return total;
}
console.log(`${(localStorageSize() / 1024).toFixed(2)} KB`);
Try/catch quota
function safeSet(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch (err) {
if (err.name === "QuotaExceededError") {
console.warn("storage full");
// Possibly evict old entries
}
return false;
}
}
A note on privacy
- Some browsers partition storage by top-level site for embedded contexts (privacy).
- Private/Incognito mode admits storage but clears at session end; quota is reduced.
- iOS Safari admits 7-day eviction for storage of low-engagement sites.
- Users can clear storage at any time — never assume persistence.
A note on the conventional discipline
The contemporary client storage advice:
- Use
localStoragefor small persistent data (preferences, drafts, UI state). - Use
sessionStoragefor tab-scoped temporary data (wizard state, in-progress forms). - Use cookies for server-readable state (auth tokens —
httponly); keep small. - Use
IndexedDB(withidbwrapper) for substantial larger structured data. - Use
Cache APIwith service workers for substantial offline. - Use URL params for substantial shareable state.
- Use
BroadcastChannelfor substantial cross-tab messaging. - Use the
storageevent for substantial cross-tablocalStoragesync. - Always JSON-serialise for
localStorage/sessionStorage. - Always check quota and handle
QuotaExceededError. - Version your stored data — admit substantial migrations.
- Reach for
navigator.storage.persist()for substantial guaranteed persistence.
The combination — localStorage/sessionStorage for substantial small data, cookies for substantial server-side, IndexedDB for substantial large data, the Cache API for substantial offline, URL params for substantial shareable state, the substantial cross-tab messaging via BroadcastChannel and the storage event — is the substance of client-side storage. The discipline is largely about choosing the right mechanism for the use case and being defensive about quota and persistence guarantees.