Polyglot
Languages Web (HTML / CSS / JS) storage
Web (HTML / CSS / JS) § storage

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=NLifetime in seconds; 0 deletes
expires=DATELifetime as date; superseded by max-age
path=/URL path scope
domain=.example.comCross-subdomain
secureHTTPS-only
httponlyNot accessible from JavaScript (set by server)
samesite=strict / lax / noneCross-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 implicationshttponly, secure, samesite matter.
  • 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.

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.
  • Versioningonupgradeneeded runs 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

APICapacityPersistenceSync?Cross-tab
localStorage~5–10 MBUntil clearedYes (blocking)Yes (storage event)
sessionStorage~5–10 MBTab lifetimeYes (blocking)No
Cookies~4 KB eachConfigurableYesYes
IndexedDBGBsUntil clearedNoVia BroadcastChannel
Cache APIGBsUntil clearedNoYes

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 localStorage for small persistent data (preferences, drafts, UI state).
  • Use sessionStorage for tab-scoped temporary data (wizard state, in-progress forms).
  • Use cookies for server-readable state (auth tokens — httponly); keep small.
  • Use IndexedDB (with idb wrapper) for substantial larger structured data.
  • Use Cache API with service workers for substantial offline.
  • Use URL params for substantial shareable state.
  • Use BroadcastChannel for substantial cross-tab messaging.
  • Use the storage event for substantial cross-tab localStorage sync.
  • 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.