21 — Storage Architecture: localStorage, IndexedDB & Fetch API Internals

localStorage / sessionStorage: Synchronous Key-Value

javascript
// ── localStorage: persistent, synchronous, ~5-10MB, strings only ──
// ── sessionStorage: per-tab, cleared on tab close ──

// ── API (synchronous — blocks the main thread) ──
localStorage.setItem("key", "value");    // store (must be a string)
localStorage.getItem("key");              // retrieve (returns string or null)
localStorage.removeItem("key");          // delete one
localStorage.clear();                     // delete all
localStorage.length;                      // number of items
localStorage.key(0);                     // key at index (iteration)

// ── Storing objects (must serialize — only strings) ──
const config = { host: "localhost", port: 3000 };
localStorage.setItem("config", JSON.stringify(config));  // serialize
const loaded = JSON.parse(localStorage.getItem("config"));  // deserialize

// ── ⚠️ localStorage is synchronous (blocks the main thread) ──
// ⚠️ Quota exceeded throws (can't catch with async — it's synchronous)
try {
    localStorage.setItem("large", "x".repeat(10_000_000));
} catch (e) {
    if (e.name === "QuotaExceededError") {
        console.log("storage full");  // ~5-10MB limit
    }
}

// ── Storage event (cross-tab synchronization) ──
window.addEventListener("storage", (e) => {
    // Fires in OTHER tabs when localStorage changes (not the tab that made the change)
    console.log("key:", e.key);       // changed key (null if clear())
    console.log("old:", e.oldValue);  // old value (null if new key)
    console.log("new:", e.newValue);  // new value (null if removed)
});
// Use case: sync state across tabs (e.g., logout in one tab → all tabs update)

IndexedDB: Asynchronous Transactional Store

javascript
// ── IndexedDB: async, transactional, ~50MB-unlimited, stores objects ──
// Much more powerful than localStorage, but complex API.

// ── Open a database ──
const request = indexedDB.open("MyApp", 1);  // name, version
request.onupgradeneeded = (e) => {
    // Called when the DB is created or version changes — create stores here
    const db = e.target.result;
    if (!db.objectStoreNames.contains("users")) {
        const store = db.createObjectStore("users", { keyPath: "id" });
        store.createIndex("email", "email", { unique: true });  // index for queries
    }
};
request.onsuccess = (e) => {
    const db = e.target.result;
    // Use the database...
};

// ── Promisified IndexedDB wrapper ──
function idbGetAll(db, storeName) {
    return new Promise((resolve, reject) => {
        const tx = db.transaction(storeName, "readonly");
        const store = tx.objectStore(storeName);
        const req = store.getAll();
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });
}

// ── Full promisified wrapper (modern approach) ──
const openDB = (name, version, onUpgrade) =>
    new Promise((resolve, reject) => {
        const req = indexedDB.open(name, version);
        req.onupgradeneeded = (e) => onUpgrade(e.target.result);
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });

async function dbExample() {
    const db = await openDB("MyApp", 1, (db) => {
        const store = db.createObjectStore("users", { keyPath: "id" });
        store.createIndex("email", "email", { unique: true });
    });

    // Add (transaction → store → add)
    const tx = db.transaction("users", "readwrite");
    await new Promise((res, rej) => {
        const req = tx.objectStore("users").add({ id: 1, name: "Alice", email: "a@b.com" });
        req.onsuccess = res;
        req.onerror = () => rej(req.error);
    });

    // Query by index
    const emailIndex = db.transaction("users", "readonly").objectStore("users").index("email");
    const user = await new Promise((res, rej) => {
        const req = emailIndex.get("a@b.com");
        req.onsuccess = () => res(req.result);
        req.onerror = () => rej(req.error);
    });
    console.log(user);  // { id: 1, name: "Alice", email: "a@b.com" }
}

Fetch API: Request/Response and Streaming

javascript
// ── Basic fetch (returns a Promise<Response>) ──
const response = await fetch("/api/data", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: "Alice" }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();  // parse JSON (returns a Promise)

// ── Response properties ──
response.ok;           // true if status is 200-299
response.status;       // HTTP status code (200, 404, 500, etc.)
response.headers.get("Content-Type");  // read response headers
response.redirected;   // true if redirected
response.type;         // "basic", "cors", "opaque", "error"

// ── Body consumption methods (each can only be called ONCE — body is a stream) ──
await response.json();     // parse as JSON
await response.text();     // read as text
await response.blob();     // read as Blob (binary data)
await response.arrayBuffer(); // read as ArrayBuffer (raw bytes)
await response.formData(); // parse as FormData

// ── Streaming response (for large data — process chunks) ──
const response = await fetch("/api/stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log(decoder.decode(value, { stream: true }));  // process each chunk
}

// ── AbortController for cancellation ──
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
    const response = await fetch("/api/slow", { signal: controller.signal });
    clearTimeout(timer);
    const data = await response.json();
} catch (e) {
    if (e.name === "AbortError") console.log("cancelled");
    else throw e;
}

Production Pattern: Offline-First with Cache API

javascript
// ── Cache API: store fetch responses for offline access (PWA pattern) ──
const CACHE_NAME = "app-v1";
const urlsToCache = ["/", "/index.html", "/styles.css", "/app.js"];

// Install: pre-cache critical resources
async function cacheResources() {
    const cache = await caches.open(CACHE_NAME);
    await cache.addAll(urlsToCache);  // fetch and store all
}

// Fetch: serve from cache, fall back to network
async function fetchWithCache(request) {
    const cached = await caches.match(request);
    if (cached) return cached;  // cache hit (offline-first)
    // Cache miss → fetch from network
    const response = await fetch(request);
    if (response.ok) {
        const cache = await caches.open(CACHE_NAME);
        cache.put(request, response.clone());  // cache for next time (clone — body can only be consumed once)
    }
    return response;
}

// Stale-while-revalidate (serve cache immediately, update in background)
async function staleWhileRevalidate(request) {
    const cache = await caches.open(CACHE_NAME);
    const cached = await cache.match(request);
    const fetchPromise = fetch(request).then(response => {
        if (response.ok) cache.put(request, response.clone());
        return response;
    });
    return cached || fetchPromise;  // serve cache immediately, update in background
}

💡 Tips & Tricks

javascript
// ── Typed-safe localStorage wrapper ──
const storage = {
    get(key, defaultValue = null) {
        const value = localStorage.getItem(key);
        if (value === null) return defaultValue;
        try { return JSON.parse(value); } catch { return value; }
    },
    set(key, value) {
        localStorage.setItem(key, JSON.stringify(value));
    },
    remove(key) { localStorage.removeItem(key); },
};

// ── Debounced localStorage writes (avoid excessive writes on input) ──
const debouncedSave = debounce((key, value) => storage.set(key, value), 500);
input.addEventListener("input", () => debouncedSave("draft", input.value));

// ── Fetch with retry and timeout ──
async function fetchWithRetry(url, { retries = 3, timeout = 5000 } = {}) {
    for (let i = 0; i < retries; i++) {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), timeout);
        try {
            const response = await fetch(url, { signal: controller.signal });
            clearTimeout(timer);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return await response.json();
        } catch (e) {
            clearTimeout(timer);
            if (i === retries - 1) throw e;
            await new Promise(r => setTimeout(r, 1000 * 2 ** i));  // exponential backoff
        }
    }
}

// ── Cookie-based auth (not localStorage for security) ──
// Store auth tokens in httpOnly cookies (not accessible via JS — XSS-resistant)
// fetch includes cookies by default in same-origin requests
// For cross-origin: credentials: "include"
fetch("/api/user", { credentials: "include" });

⚠️ Edge Cases & Gotchas

javascript
// ── localStorage is synchronous (blocks the main thread) ──
// ⚠️ Don't store large data in localStorage — it blocks the UI.
// Use IndexedDB for large data (async, non-blocking).

// ── localStorage stores strings only ──
localStorage.setItem("count", 42);  // stored as "42" (string)
const count = localStorage.getItem("count");  // "42" (string — must parse)
// Use JSON.stringify/parse for non-string values.

// ── localStorage quota (~5-10MB) — throws on overflow ──
try { localStorage.setItem("big", "x".repeat(20_000_000)); }
catch (e) { /* QuotaExceededError */ }

// ── localStorage is per-origin (protocol + domain + port) ──
// http://example.com and https://example.com have DIFFERENT localStorage

// ── fetch body can only be consumed ONCE ──
const response = await fetch("/api");
await response.text();   // consume body
// await response.json();  // ✗ TypeError: body already consumed
// Clone if you need to read twice: const clone = response.clone();

// ── fetch doesn't reject on HTTP errors (only on network errors) ──
const response = await fetch("/api/404");
// response.ok === false, but the Promise is RESOLVED (not rejected)
// Must check response.ok manually:
if (!response.ok) throw new Error(`HTTP ${response.status}`);

// ── IndexedDB transactions auto-commit if no operations are pending ──
// Don't mix async (await) between transaction creation and operation — the transaction
// may auto-commit before the operation runs.

🧠 Quick Quiz

Why does this throw a TypeError, and how do you fix it?

javascript
const response = await fetch("/api/data");
const text = await response.text();
const json = JSON.parse(text);
const blob = await response.blob();  // TypeError!
Answer

The response.body is a stream — it can only be consumed once. After await response.text(), the stream is exhausted. Calling await response.blob() throws TypeError: Body has already been consumed.

Fix: clone the response before consuming (each clone has its own body stream):

const response = await fetch("/api/data");
const clone = response.clone();  // create a copy with an independent body stream
const text = await response.text();
const blob = await clone.blob();  // ✓ works (clone has its own body)

Or, decide which format you need and consume it once:

const response = await fetch("/api/data");
const json = await response.json();  // consume once as JSON

The lesson: a Response body is a one-time stream. Reading it with .text(), .json(), .blob(), or .arrayBuffer() consumes it permanently. Use .clone() if you need to read the body in multiple formats.