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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- browser-storage Installs to ~/.agents/skills/browser-storage/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install browser-storage The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project browser-storage Installs to .agents/skills/browser-storage/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/browser-storage.zipunzip -o browser-storage.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) browser-storage PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
| Need | Store | Limits 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) | sessionStorage | Per tab, cleared on close; duplicated when a tab is duplicated |
| Structured app data, offline records, blobs | IndexedDB (via idb or Dexie) | Async, large (share of disk), indexes, transactions |
| HTTP responses and assets for offline | Cache API (usually from a service worker) | Request/response pairs |
| Large files, SQLite databases, random-access binary | OPFS (navigator.storage.getDirectory()) | Fast sync access handles in workers only |
| Session identity, anything the server must read | Cookie, HttpOnly; Secure; SameSite=Lax | ~4 KB each, sent on every matching request |
| Values that must not persist | Memory (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
- Read
AGENTS.md; look for existing storage wrappers, key naming, and SSR framework. - Inventory current keys: search for
localStorage.,indexedDB.open,document.cookie,openDB(,new Dexie(. Name every key/database with an app prefix and version. - 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
blockingby closing the connection, or users with two tabs get stuck. - Dexie is a good alternative when you want a query API and live queries
(
useLiveQueryin 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 byupdatedAtindex) and retry once; then degrade (stop caching) rather than crash.
Cookies
Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600
| Attribute | Rule |
|---|---|
HttpOnly | Always for session or auth cookies |
Secure | Always (required for SameSite=None and __Host-) |
SameSite | Lax default; Strict for high-value actions; None only for real cross-site use |
__Host- prefix | Locks to host, Path=/, no Domain: use for session cookies |
Partitioned | Third-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 whenlocalStoragechanges (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 sendClear-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,localStorageandindexedDBdo 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/middlewarepersist, Pinia plugins) need a version andmigratefunction just like hand-written stores.
Gotchas
JSON.stringifydropsundefined, turnsDateinto strings andMap/Setinto{}; IndexedDB uses structured clone and keeps them.localStoragevalues are per origin:app.example.comandwww.example.comdo not share.sessionStorageis 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
localStoragebut no IndexedDB; usefake-indexeddbin 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.yaml
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.