Browser Storage skill for Codex

The right store for every byte you keep client-side. Codex chooses storage by data shape, size and lifetime instead of defaulting to localStorage. Reads tolerate missing, corrupt and old-version data, writes report quota failures, IndexedDB schemas migrate with cumulative upgrade blocks, tabs stay in sync over BroadcastChannel, and session tokens live in HttpOnly cookies rather than anywhere an injected script could read them.

  • Frontend
  • $browser-storage
  • 200 lines
  • 2 files
  • ~2,300 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- browser-storage

Installs to ~/.agents/skills/browser-storage/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $browser-storage, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Chooses and implements client-side persistence - localStorage, sessionStorage, IndexedDB (idb, Dexie), Cache API, OPFS with SQLite WASM, and cookies with HttpOnly, SameSite and partitioning - including schema versioning, quota and eviction handling, cross-tab sync with BroadcastChannel, SSR guards and keeping tokens out of script-readable storage. Use when an app needs drafts, offline data, caching or preferences in the browser, when storage throws QuotaExceededError, or when deciding where auth tokens belong.

Use it when

  • Persisting drafts, preferences, offline records or caches in the browser
  • Choosing between localStorage, IndexedDB, OPFS, Cache API and cookies
  • Storage throws quota or security errors, or data vanishes unexpectedly
  • Deciding where authentication tokens and session data should live

Use something else when

  • You need a full offline app with caching strategies and install prompts: use pwa-service-workers
  • The question is server-side session or token design: use session-auth

How to check the result

Seed old-version and corrupted data and confirm the app loads, simulate a small quota in DevTools, and check that no tokens appear in script-readable storage and logout clears every store.

The mistake it prevents

Keeping access tokens in localStorage, where any cross-site scripting bug can read and exfiltrate them.

~/.agents/skills/browser-storage/SKILL.md

--- name: browser-storage description: Chooses and implements client-side persistence - localStorage, sessionStorage, IndexedDB (idb, Dexie), Cache API, OPFS with SQLite WASM, and cookies… license: MIT ---

Pick the store by data shape, size, lifetime and who must read it (JS, the server, a service worker). Done well: data survives exactly as long as intended, writes cannot silently fail, schema changes migrate old data, and nothing an attacker’s script could steal sits in script-readable storage.

Choose the store

NeedStoreLimits and behaviour
Small UI prefs (theme, collapsed panels)localStorage~5 MB per origin, strings only, synchronous (blocks main thread)
Per-tab state (wizard step, scroll)sessionStoragePer tab, cleared on close; duplicated when a tab is duplicated
Structured app data, offline records, blobsIndexedDB (via idb or Dexie)Async, large (share of disk), indexes, transactions
HTTP responses and assets for offlineCache API (usually from a service worker)Request/response pairs
Large files, SQLite databases, random-access binaryOPFS (navigator.storage.getDirectory())Fast sync access handles in workers only
Session identity, anything the server must readCookie, HttpOnly; Secure; SameSite=Lax~4 KB each, sent on every matching request
Values that must not persistMemory (module state, React state)Gone on reload

Rules of thumb: never use localStorage for data over ~100 KB or written often (it is synchronous and serialises on every write); never use cookies for client-only data (bandwidth on every request); never put auth tokens in localStorage, sessionStorage or IndexedDB (any XSS reads them).

Before writing code

  1. Read AGENTS.md; look for existing storage wrappers, key naming, and SSR framework.
  2. Inventory current keys: search for localStorage., indexedDB.open, document.cookie, openDB(, new Dexie(. Name every key/database with an app prefix and version.
  3. Decide lifetime and ownership per item: who writes, who reads, when it expires, what happens on logout.

Safe localStorage wrapper

type Stored<T> = { v: number; data: T; exp?: number };

export function createStore<T>(key: string, version: number, fallback: T, migrate?: (old: unknown, from: number) => T) {
  const fullKey = `acme:${key}`;
  return {
    get(): T {
      try {
        const raw = globalThis.localStorage?.getItem(fullKey);
        if (!raw) return fallback;
        const parsed = JSON.parse(raw) as Stored<unknown>;
        if (parsed.exp && Date.now() > parsed.exp) { localStorage.removeItem(fullKey); return fallback; }
        if (parsed.v !== version) return migrate ? migrate(parsed.data, parsed.v) : fallback;
        return parsed.data as T;
      } catch {
        return fallback;                 // corrupted JSON, storage disabled, SSR
      }
    },
    set(data: T, ttlMs?: number): boolean {
      try {
        const value: Stored<T> = { v: version, data, exp: ttlMs ? Date.now() + ttlMs : undefined };
        localStorage.setItem(fullKey, JSON.stringify(value));
        return true;
      } catch {
        return false;                    // QuotaExceededError, SecurityError: caller decides
      }
    },
    remove() { try { localStorage.removeItem(fullKey); } catch {} },
  };
}

Every read tolerates missing, corrupt and old-version data; every write reports failure. Access throws SecurityError when storage is blocked (some privacy settings, sandboxed iframes), so even localStorage itself must be inside try.

IndexedDB with idb

import { openDB, type DBSchema } from 'idb';

interface AppDB extends DBSchema {
  drafts: { key: string; value: { id: string; body: string; updatedAt: number }; indexes: { byUpdated: number } };
  outbox: { key: number; value: { url: string; body: unknown; createdAt: number } };
}

export const dbPromise = openDB<AppDB>('acme', 2, {
  upgrade(db, oldVersion, _newVersion, tx) {
    if (oldVersion < 1) {
      db.createObjectStore('drafts', { keyPath: 'id' });
    }
    if (oldVersion < 2) {
      tx.objectStore('drafts').createIndex('byUpdated', 'updatedAt');
      db.createObjectStore('outbox', { autoIncrement: true });
    }
  },
  blocked() { /* another tab holds an older version open: ask user to close it */ },
  blocking() { /* this tab blocks a newer version: close and reload */ },
});

export async function saveDraft(id: string, body: string) {
  const db = await dbPromise;
  await db.put('drafts', { id, body, updatedAt: Date.now() });
}
  • Migrations are cumulative if (oldVersion < n) blocks; never edit a shipped block.
  • Schema changes only happen in upgrade; bumping the version is the only way in.
  • Transactions auto-commit when no requests are pending: do not await fetch() inside a transaction, it will be closed when you come back.
  • Handle blocking by closing the connection, or users with two tabs get stuck.
  • Dexie is a good alternative when you want a query API and live queries (useLiveQuery in React). Pick one and wrap it in a repository module.

Quota, persistence and eviction

const { usage, quota } = await navigator.storage.estimate();
const persisted = await navigator.storage.persisted?.();
// Ask only after a user action that signals intent (e.g. "make available offline")
const granted = await navigator.storage.persist?.();
  • Default storage is “best effort”: the browser may evict it under disk pressure, least-recently-used origins first.
  • Safari deletes script-writable storage (localStorage, IndexedDB, Cache API) for sites without user interaction in 7 days of browser use, unless the site is installed to the home screen. Treat client storage as a cache of server state for anything important.
  • Private browsing modes give small, session-only quotas.
  • On QuotaExceededError, evict your own oldest data (LRU by updatedAt index) and retry once; then degrade (stop caching) rather than crash.

Cookies

Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600
AttributeRule
HttpOnlyAlways for session or auth cookies
SecureAlways (required for SameSite=None and __Host-)
SameSiteLax default; Strict for high-value actions; None only for real cross-site use
__Host- prefixLocks to host, Path=/, no Domain: use for session cookies
PartitionedThird-party embeds that need cookies under third-party cookie restrictions (CHIPS)

Client-side cookie reads and writes (document.cookie, cookieStore) are for non-sensitive values only. The server sets auth cookies.

Cross-tab sync

  • window.addEventListener('storage', e => ...) fires in other tabs when localStorage changes (not in the tab that wrote it).
  • For IndexedDB or app events, use BroadcastChannel:
const channel = new BroadcastChannel('acme-sync');
channel.postMessage({ type: 'draft-saved', id });
channel.onmessage = (e) => { if (e.data.type === 'logout') clearLocalData(); };
  • On logout: clear IndexedDB stores and keys that belong to the user, broadcast logout, and have the server send Clear-Site-Data: "storage" if all origin storage should go.

OPFS for heavy data

Use OPFS with the official SQLite WASM build (or wa-sqlite) when you need SQL, large datasets, or file-like random access. Synchronous access handles (fileHandle.createSyncAccessHandle()) exist only in dedicated workers; run the database in a worker and talk to it via messages. Requires cross-origin isolation only for some SQLite VFS options; check the library’s docs for headers.

SSR and frameworks

  • window, localStorage and indexedDB do not exist on the server. Read storage in effects (useEffect, onMounted) or client-only modules.
  • Rendering stored preferences (theme) on the server causes hydration mismatch; put them in a cookie the server can read, or render a neutral state and update after mount.
  • Persisted state libraries (zustand/middleware persist, Pinia plugins) need a version and migrate function just like hand-written stores.

Gotchas

  • JSON.stringify drops undefined, turns Date into strings and Map/Set into {}; IndexedDB uses structured clone and keeps them.
  • localStorage values are per origin: app.example.com and www.example.com do not share.
  • sessionStorage is copied into a duplicated tab; unique per-tab ids must be regenerated.
  • Storing PII client-side expands the data you must protect and delete on request; store the minimum and clear it on logout.
  • Tests: jsdom has localStorage but no IndexedDB; use fake-indexeddb in unit tests and real-browser tests for quota and eviction behaviour.

Verification

  • DevTools Application panel shows expected keys/stores with app prefix and version
  • Old-version and corrupted data load without errors (seed them manually)
  • Simulated quota exceeded (DevTools “custom storage quota”) degrades gracefully
  • Two tabs: changes and logout propagate; IndexedDB upgrade with old tab open is handled
  • No tokens or secrets in localStorage, sessionStorage or IndexedDB
  • Logout clears user data in every store

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Browser Storage"
  short_description: "The right store for every byte you keep client-side"
  default_prompt: "$browser-storage Save editor drafts locally so users don't lose work if the tab crashes"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem