Frontend Labs
⋯

public/js/stations/storage.js

// Station 6: IndexedDB and friends (§43–§45, §82). Every request and
// transaction event is logged as it fires, so you can see the lifecycle.
import { h, station, panel, button, sleep, ms, bytes } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";

const DB_NAME = "browser-lab";
const t0 = performance.now();

function openDB(log) {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    log("indexedDB.open('browser-lab', 1) → request created (nothing has happened yet)", "req");
    req.onupgradeneeded = () => {
      log("upgradeneeded: version 0 → 1, creating object store 'notes' + index 'byCreated'", "evt");
      const store = req.result.createObjectStore("notes", { keyPath: "id", autoIncrement: true });
      store.createIndex("byCreated", "created");
    };
    req.onsuccess = () => {
      log("open success: we have a connection", "ok");
      resolve(req.result);
    };
    req.onerror = () => reject(req.error);
    req.onblocked = () => log("blocked: another tab holds an older version open", "bad");
  });
}

// Wrap a request in a promise, logging its success.
const done = (req, log, label) =>
  new Promise((resolve, reject) => {
    req.onsuccess = () => {
      log(`${label} → success`, "ok");
      resolve(req.result);
    };
    req.onerror = () => reject(req.error);
  });

export function mount() {
  const logEl = h("ol", { class: "log", "aria-label": "IndexedDB event log" });
  const log = (text, kind = "") => logEl.prepend(h("li", { "data-kind": kind }, `+${ms(performance.now() - t0)}  ${text}`));
  const list = h("ul", { class: "tree", "aria-label": "Stored notes" });
  const title = h("input", { "aria-label": "Note text", value: "Hello from IndexedDB" });
  let dbp = openDB(log);

  const refresh = async () => {
    const db = await dbp;
    const tx = db.transaction("notes", "readonly");
    const items = [];
    await new Promise((resolve) => {
      tx.objectStore("notes").index("byCreated").openCursor(null, "prev").onsuccess = (e) => {
        const cursor = e.target.result;
        if (cursor) {
          items.push(cursor.value);
          cursor.continue(); // each step is another async success event
        } else resolve();
      };
    });
    list.replaceChildren(...items.map((n) => h("li", {}, `#${n.id} · ${n.text} · ${new Date(n.created).toLocaleTimeString()}`)));
    return items;
  };

  const add = async () => {
    const db = await dbp;
    const tx = db.transaction("notes", "readwrite");
    log("transaction('notes', 'readwrite') → active", "evt");
    tx.oncomplete = () => log("transaction complete: the write is durable", "ok");
    const id = await done(tx.objectStore("notes").add({ text: title.value, created: Date.now() }), log, "store.add()");
    await new Promise((r) => tx.addEventListener("complete", r));
    const items = await refresh();
    if (items.some((n) => n.id === id)) missions.complete("storage", "idb");
  };

  const clear = async () => {
    const db = await dbp;
    const tx = db.transaction("notes", "readwrite");
    await done(tx.objectStore("notes").clear(), log, "store.clear()");
    refresh();
  };

  // The classic bug: awaiting something that is not an IDB request inside a
  // transaction. The transaction auto-commits when it has no pending
  // requests at the end of a task, so the next request throws.
  const inactive = async () => {
    const db = await dbp;
    const tx = db.transaction("notes", "readwrite");
    const store = tx.objectStore("notes");
    log("transaction started; now awaiting a 10ms timer inside it…", "evt");
    tx.oncomplete = () => log("…the transaction already auto-committed (no pending requests)", "evt");
    await sleep(10);
    try {
      store.add({ text: "never written", created: Date.now() });
    } catch (err) {
      log(`${err.name}: ${err.message}`, "bad");
      missions.complete("storage", "inactive");
    }
  };

  const blocking = async () => {
    const big = "x".repeat(2 * 1024 * 1024);
    let t = performance.now();
    try {
      localStorage.setItem("browser-lab:big", big);
    } catch (err) {
      log(`localStorage: ${err.name}`, "bad");
    }
    const lsMs = performance.now() - t;
    localStorage.removeItem("browser-lab:big");
    const db = await dbp;
    t = performance.now();
    const tx = db.transaction("notes", "readwrite");
    tx.objectStore("notes").put({ id: -1, text: big, created: 0 });
    const idbMainMs = performance.now() - t; // time the main thread was busy issuing the write
    await new Promise((r) => (tx.oncomplete = r));
    const idbTotal = performance.now() - t;
    const tx2 = db.transaction("notes", "readwrite");
    tx2.objectStore("notes").delete(-1);
    log(`2 MB write: localStorage blocked the main thread ${ms(lsMs)}; IndexedDB blocked it ${ms(idbMainMs)} (finished ${ms(idbTotal)} later, off-thread)`, "ok");
  };

  const quota = h("dl", { class: "kv" });
  (async () => {
    const est = await navigator.storage?.estimate?.();
    const persisted = await navigator.storage?.persisted?.();
    quota.append(
      h("dt", {}, "Origin usage"), h("dd", {}, bytes(est?.usage)),
      h("dt", {}, "Origin quota"), h("dd", {}, bytes(est?.quota)),
      h("dt", {}, "Persistent?"), h("dd", {}, String(persisted)),
    );
  })();

  refresh();
  return {
    el: station(
      "IndexedDB & storage",
      "IndexedDB is a transactional, asynchronous object database in every browser. Requests fire events; transactions commit on their own when nothing is pending. Watch it happen.",
      missionList("storage"),
      h("div", { class: "grid-2" },
        panel("Notes in IndexedDB",
          h("div", { class: "row" }, title, button("Add note", add, { class: "primary" }), button("Clear", clear)),
          list),
        panel("Event log (newest first)", logEl)),
      h("div", { class: "grid-2" },
        panel("Pitfall: the transaction that commits behind your back",
          h("p", {}, "await a timer or fetch() inside a transaction and it auto-commits. Your next request throws TransactionInactiveError."),
          button("Trigger it", inactive, { class: "danger" })),
        panel("Sync vs async storage",
          h("p", {}, "localStorage is synchronous: a big write blocks the main thread. IndexedDB does its I/O off the main thread."),
          button("Write 2 MB to each", blocking), quota)),
      panel("Inspect it", h("p", { class: "note" }, "DevTools → Application → IndexedDB → browser-lab → notes. Right-click → Refresh after adding notes. Storage → \"Clear site data\" wipes everything for this origin.")),
    ),
    unmount: async () => (await dbp).close(),
  };
}

Generated from SOURCE.md · 8 Oct 2026 16:36