Frontend / Browser internals / 04_storage_apis.md

Storage APIs — localStorage, sessionStorage, IndexedDB, Cookies, Cache API, OPFS

Updated 6 interview angles 8 min read source
On this page14
  1. The six storage layers — what for?
  2. localStorage — when use, when not.
  3. sessionStorage — same API, different scope.
  4. IndexedDB — what’s the model?
  5. Cookies — what makes them different?
  6. Cache API — what is it?
  7. Origin Private File System (OPFS).
  8. Storage quota — how much can you use?
  9. How do you handle storage version migrations?
  10. Cross-tab sync — same-origin tabs sharing state.
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Storage APIs — localStorage, sessionStorage, IndexedDB, Cookies, Cache API, OPFS

The browser ships six+ storage layers, each with different semantics. localStorage (synchronous, ~5MB, string-only) is the easy default for tiny persistent state. IndexedDB is the async, large, indexed store for real client data. Cookies are for things the server needs (auth, CSRF). Cache API is for service worker responses. OPFS (Origin Private File System) is the newer high-perf binary store. The senior choices: never put tokens in localStorage, never block the main thread with sync storage, respect quotas, and plan for eviction.

The six storage layers — what for?

Storage Capacity, API Use for
localStorage ~5-10MB; sync, blocks tiny UI prefs, theme, draft text
sessionStorage ~5-10MB; sync per-tab state
IndexedDB hundreds of MB+; async real client data, offline content
Cache API quota-limited; async (Promise) HTTP responses, in a service worker
Cookies ~4KB each; sync via document.cookie auth (httpOnly), CSRF; sent on every request
OPFS quota-limited; async high-perf binary, virtual filesystem
(older) WebSQL — deprecated: don’t use

Plus framework abstractions: localForage (wraps IndexedDB), Dexie (IndexedDB query layer), Pinia/Redux persist (state manager → storage adapters).

localStorage — when use, when not.

js
localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme") ?? "light";
localStorage.removeItem("theme");
localStorage.clear();

// JSON pattern
localStorage.setItem("user", JSON.stringify(user));
const user = JSON.parse(localStorage.getItem("user") ?? "null");

Pros: trivial API, persists across sessions, isolated per origin.

Cons:

  • Synchronous — reads/writes block the main thread. Don’t read/write in tight loops or hot paths.
  • String-only — must serialize/parse JSON manually.
  • ~5MB cap — exceed and setItem throws QuotaExceededError.
  • Not for sensitive data — readable by any JS on the origin. Never store auth tokens here; XSS gives away your session.

Use for: theme/locale prefs, last-visited page, “remember me” flag (not the token), draft form input.

sessionStorage — same API, different scope.

Same API as localStorage, but cleared when the tab closes (per-tab isolation). Useful for:

  • Step-by-step wizard state.
  • Per-tab user preferences (“dark mode for this tab”).
  • Auth-like context that shouldn’t survive close.

Note: sessionStorage is per-tab, not per-window or per-origin globally. Opening the same URL in a new tab gets a fresh sessionStorage.

IndexedDB — what’s the model?

A transactional, indexed, async key-value store with structured data + queries.

js
const req = indexedDB.open("myDb", 1);

req.onupgradeneeded = (e) => {
  const db = (e.target as IDBOpenDBRequest).result;
  const store = db.createObjectStore("posts", { keyPath: "id" });
  store.createIndex("byDate", "createdAt");
};

req.onsuccess = (e) => {
  const db = e.target.result;
  const tx = db.transaction("posts", "readwrite");
  tx.objectStore("posts").put({ id: 1, title: "Hello", createdAt: Date.now() });
  tx.oncomplete = () => console.log("done");
};

Use a wrapper

The raw API is clunky (callbacks, not promises). Use a wrapper:

  • idb (Jake Archibald) — promisified IndexedDB. Lightweight, type-safe.
  • Dexie — full ORM-ish abstraction with queries.
  • localForage — drop-in replacement for localStorage API, IndexedDB underneath.
js
// idb example
import { openDB } from "idb";

const db = await openDB("myDb", 1, {
  upgrade(db) {
    db.createObjectStore("posts", { keyPath: "id" });
  },
});

await db.put("posts", { id: 1, title: "Hello" });
const post = await db.get("posts", 1);
const all = await db.getAll("posts");

Capacity: hundreds of MB+ typically. Supports binary data, transactions, indexes.

Use cases: offline-capable apps (cached posts, drafts), large data (search index, image blobs, model files), structured queries beyond key-value.

Cookies — what makes them different?

Cookies are sent on every HTTP request to their domain. That’s the fundamental difference.

js
// Reading (JS-accessible cookies only)
document.cookie;   // "theme=dark; lang=en"

// Setting
document.cookie = "theme=dark; max-age=2592000; path=/; samesite=strict; secure";

Attributes:

  • max-age / expires — lifetime.
  • path — URL path scope.
  • domain — host scope.
  • secure — HTTPS only.
  • httpOnly — JS cannot read (server-set only). Critical for auth tokens.
  • samesite — strict / lax / none — CSRF protection.

For auth: server sets a httpOnly; secure; samesite=lax cookie. JS can’t read it (so XSS can’t steal); browser sends it automatically with API requests. Always prefer this over storing tokens in localStorage. See Frontend Security — Senior Interview Prep.

Capacity: ~4KB per cookie, ~50 cookies per domain typically. Don’t use for app data — use IndexedDB/localStorage. Use cookies for things the server needs (session, CSRF, A/B test bucket).

Cache API — what is it?

Programmatic HTTP-response caching, separate from the browser’s HTTP cache. Lives in the service worker context typically.

js
// Inside a service worker
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached ?? fetch(event.request).then((response) => {
        return caches.open("v1").then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

You control: which requests cache, how long, when to invalidate. Different from the HTTP cache (Cache-Control headers) — Cache API is yours to manipulate.

Use cases: offline-first apps, app-shell caching, response staleness control, prefetch via worker. Pairs with service workers (next file).

Origin Private File System (OPFS).

A high-performance, sandboxed virtual filesystem for the origin. Async API.

js
const root = await navigator.storage.getDirectory();
const file = await root.getFileHandle("data.bin", { create: true });
const writable = await file.createWritable();
await writable.write(blob);
await writable.close();

const handle = await root.getFileHandle("data.bin");
const f = await handle.getFile();
const buffer = await f.arrayBuffer();

Faster than IndexedDB for binary data; supports streaming writes; no JSON-serialization overhead. Use for:

  • Large file caches (PDFs, model files, video chunks).
  • Streaming uploads (write chunks as they arrive).
  • WASM apps that need an actual filesystem.

Browser support: Chrome 102+, Safari 15.4+, Firefox 111+. Quotas tied to storage quota (see below).

Storage quota — how much can you use?

Browsers grant ~5-50% of the disk to the origin’s total storage (IndexedDB + Cache API + OPFS combined). localStorage and cookies are separately small-capped.

js
const { usage, quota } = await navigator.storage.estimate();
console.log(`Using ${usage} of ${quota} bytes`);

If you’re approaching quota, request persistent storage — tells the browser to avoid evicting your data under disk pressure:

js
const persistent = await navigator.storage.persist();
// true if granted
console.log("persistent:", persistent);

By default, “best-effort” storage can be evicted (LRU). Persistent storage is opt-in and may require user permission for non-installed sites.

How do you handle storage version migrations?

IndexedDB has built-in: bump the version, upgrade callback runs:

js
const db = await openDB("myDb", 2, {
  upgrade(db, oldVersion, newVersion, tx) {
    if (oldVersion < 1) db.createObjectStore("posts", { keyPath: "id" });
    if (oldVersion < 2) db.createObjectStore("drafts", { keyPath: "id" });
  },
});

For localStorage, you migrate manually — read the old format, transform, write the new:

js
const VERSION = "v2";
if (localStorage.getItem("storageVersion") !== VERSION) {
  migrate(localStorage);
  localStorage.setItem("storageVersion", VERSION);
}

Plan migrations forward; never reformat without a version check.

Cross-tab sync — same-origin tabs sharing state.

Multiple options:

  • storage event — fires in other tabs when localStorage changes:
    js
    window.addEventListener("storage", (e) => {
      console.log(e.key, e.oldValue, e.newValue);
    });
  • BroadcastChannel — explicit cross-tab pub/sub:
    js
    const ch = new BroadcastChannel("auth");
    ch.postMessage({ type: "logout" });
    ch.addEventListener("message", (e) => { /* ... */ });
  • Shared workers — multiple tabs share one worker (see Web Workers, SharedWorker, BroadcastChannel).
  • localStorage poll — old hack, don’t use.

Common pattern: user logs out in tab A → BroadcastChannel notifies tab B → tab B reloads its auth state.

Gotchas

  • localStorage is synchronous — reading 100 keys in a loop blocks the main thread. Batch via JSON serialize/parse if needed.
  • localStorage event fires in other tabs, not the originating one — easy to miss.
  • QuotaExceededError — handle for setItem writes. Implement LRU eviction or surface to the user.
  • Private/incognito mode disables or limits storage — setItem may throw immediately. Detect and fall back to in-memory.
  • indexedDB blocks across versions — opening v2 while v1 is open in another tab → blocked. Handle the onblocked event.
  • Cookies vs Set-Cookie samesite=none requires secure (HTTPS); otherwise the browser drops the cookie silently.
  • Storage limits per third-party iframe — partitioned storage in modern browsers (Chrome’s storage partitioning) gives each (top-frame, embedded-frame) pair its own storage. May surprise embedded apps.

Further reading

Interview angle 6

  • “Which storage API for what?” - localStorage for small synchronous UI preferences, and it is string-only and blocks the main thread, so it does not scale. IndexedDB for real structured client data - asynchronous, indexed, hundreds of megabytes. Cookies only for things the server needs on each request.
  • “Where do auth tokens go?” - not in localStorage, because any script on the page can read them and an XSS exfiltrates them permanently. An HttpOnly, Secure, SameSite cookie, so script cannot reach the credential at all.
  • “Would you use IndexedDB directly?” - no. The raw API is callback-based with transaction semantics that are easy to misuse, so a wrapper such as idb or Dexie is standard. Reaching for the raw API is a choice that needs a reason.
  • “What is OPFS for?” - the origin private file system, which is significantly faster than IndexedDB for large binary data and streaming. It is the right answer for a file-heavy application - an editor, a media tool - and overkill for anything else.
  • “What is the quota trap?” - the quota is shared across IndexedDB, the Cache API and OPFS, and the browser will evict all of it under disk pressure. If the data matters, request persistent storage explicitly rather than assuming what you wrote is still there.
  • “How do you keep tabs in sync?” - BroadcastChannel for explicit messaging between tabs, the storage event for localStorage changes, and a SharedWorker when you want one authoritative connection or state shared by every tab.