Storage
A page can keep bytes in seven places and each was built for a different job. This part is the map of them by size and synchronicity, cookies as a transport mechanism and every attribute that filters them, why localStorage is a startup cost, the IndexedDB transaction model and the await trap, the Cache API and the real offline contract, OPFS and SQLite in the browser, and the quota, eviction and partitioning rules that decide whether your data survives.
The storage map: what exists and why there are so many
"Where should the app keep this? Cookie, localStorage, IndexedDB, Cache API, or something else?"
The browser has at least seven ways to keep bytes on the user's disk because they were added at different times for different jobs and none replaced another. The way to choose is by two properties: does the API block the main thread, and how much can it hold.
| API | Sync? | Size | Workers? | Sent to server? | Value type | Built for |
|---|---|---|---|---|---|---|
| Cookies | Yes (document.cookie); async via cookieStore | ~4 KB each, ~180 per domain | SW via cookieStore | Yes, every matching request | String | Session identity, server-read state |
| localStorage | Yes | ~5 MB | No | No | String | Tiny flags: theme, dismissed banner |
| sessionStorage | Yes | ~5 MB | No | No | String | Per-tab scratch; survives reload, not close |
| IndexedDB | No | Origin quota (GBs) | Yes | No | Structured clone | Records, offline data, queues |
| Cache API | No | Origin quota | Yes | No | Response | Assets and API responses for offline |
| OPFS | Sync in workers | Origin quota | Yes | No | Bytes | File-shaped data; SQLite in Wasm |
| File System Access | No | Unlimited (user's files) | Handles yes | No | Bytes | Editing the user's real files, with permission |
- Anything with a size goes in IndexedDB or Cache API. localStorage is a convenience for a handful of strings. People put 2 MB of JSON in it and then wonder why startup is slow: the read is synchronous and the parse is on the main thread.
- Anything the server must see goes in a cookie, and nothing else does. Every byte of cookie is sent on every request to that domain, including images.
- Anything a worker must reach is IndexedDB, Cache API or OPFS. localStorage does not exist in workers.
- Anything that must survive the browser's own cleanup needs persist(), covered later.
Cookies: a transport mechanism that happens to persist
Cookies predate every other storage API and were designed for a different job: letting the server keep state across stateless HTTP. Every attribute on a cookie is a filter on when it gets attached to a request or who can read it, and the security of a session depends on getting those filters right.
- Domain. Which hosts receive it. Unset means the exact host only (no subdomains).
Domain=example.commeans example.com and all subdomains. A cookie cannot be set for a parent that is a public suffix (.co.uk). - Path. Which URL paths. Rarely a security boundary; same-origin script can read all paths. Set it to
/. - Secure. Only over https. Without it, a plain-http request to the same host leaks the cookie.
- HttpOnly. Invisible to
document.cookieand cookieStore. The reason XSS on a well-configured site cannot steal the session directly. - SameSite.
Strict: never on cross-site requests, including the user clicking a link to your site (they arrive logged out).Lax(default in Chrome): sent on top-level GET navigations, not on cross-site subrequests or POSTs.None: always sent; requires Secure; what embedded widgets and cross-site SSO need. - Expires / Max-Age. Without either, a session cookie: gone when the browser closes (except browsers that restore sessions keep them). Chrome caps the maximum at 400 days.
- Prefixes.
__Host-requires Secure, no Domain, Path=/, so no subdomain can plant a cookie of that name.__Secure-requires Secure. The prefix is enforced by the browser at set time. - Partitioned. CHIPS: a third-party cookie that gets a separate jar per top-level site, so an embed can keep state per embedding site without cross-site tracking.
a session cookie done properly: Set-Cookie: __Host-session=…; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=1209600 __Host- → no subdomain can overwrite it HttpOnly → XSS cannot read it Lax → cross-site POSTs do not carry it (CSRF) Secure → never on http and a CSRF token on state-changing requests anyway, because SameSite has edge cases (older browsers, same-site subdomains you do not control).
localStorage and sessionStorage: small, synchronous, and misused
Web Storage is a string-keyed, string-valued map with a synchronous API. The synchronicity is the whole story: every call is on the main thread, and in some browsers the first access of a page load reads the entire store from disk before returning.
- Read.
localStorage.getItem(k). Chrome loads the origin's whole localStorage into renderer memory on first access (a synchronous IPC to the browser process) and serves later reads from that copy. Firefox and Safari have similar first-access costs. A 5 MB store costs milliseconds to load before the first getItem returns. - Write.
setItem(k, v)stringifies the value, updates the in-memory copy, and asynchronously persists. Thestorageevent fires in other same-origin documents, not the one that wrote. That event is a usable cross-tab signal. - Quota. Around 5 MB per origin (10 MB in some). Exceeding it throws QuotaExceededError synchronously. Safari in private browsing historically threw on any write.
- Partition. Per origin, and in modern browsers per top-level site as well. An iframe's localStorage on site A is not its localStorage on site B.
- sessionStorage is the same with a lifetime of one tab: survives reload and same-tab navigation, copied on window.open from that tab, gone when the tab closes.
- A theme preference, a dismissed-banner flag, the last-selected tab. Strings under a kilobyte, read once at startup.
- A cross-tab signal via the storage event (logout in one tab logs out all).
- Not: auth tokens (XSS reads them in one line; HttpOnly cookies exist for this). Not: cached API responses with any size (parse cost on the main thread, 5 MB cap). Not: anything a worker needs.
IndexedDB: the client database
IndexedDB is a transactional, indexed key-value store with structured-clone values. It is the only general-purpose database in the browser, it is available in workers, and its API is from 2010, event-based and verbose. Everyone wraps it; the wrapper you choose should not hide the transaction model, because the transaction model is where the bugs are.
- Database, named, versioned. Object stores within it, each with a key path or auto-increment key. Indexes on stores, over a property path, unique or not, multiEntry for arrays.
- Schema changes only in upgradeneeded. Opening with a higher version number triggers it; the versionchange transaction is the only context where createObjectStore and createIndex work. Other open connections get a
versionchangeevent and must close, or the upgrade blocks. - Transactions over a list of stores in readonly or readwrite mode. Readwrite transactions touching overlapping stores are serialised; readonly ones overlap. Auto-commit when no requests are pending at the end of the current task.
- Requests within a transaction: get, getAll, put, add, delete, openCursor, count. Each is asynchronous, returns an IDBRequest, delivers success or error as an event.
- Cursors iterate a store or index in key order, forward or backward, optionally over a key range. The way to stream a large result instead of getAll-ing it into memory.
- await inside a transaction. Any await on a non-IDB promise (fetch, a timer, even a resolved promise in some versions) lets the transaction auto-commit. The next request throws TransactionInactiveError. Gather inputs first, then open the transaction and issue all requests synchronously in a chain.
- Structured clone cost. Every put clones the value; every get reconstructs it. A 10 MB object is 10 MB of cloning per operation. Store large blobs as Blob (stored by reference, not re-cloned) and keep records small.
- Safari. Historically the weakest implementation: a 7-day eviction policy for sites without interaction, bugs with Blobs, the "hang on first open" in some versions. Test there.
- Version conflicts across tabs. Deploying a schema change while a user has an old tab open blocks the upgrade. Handle
blockedandversionchange: close the old connection, tell the user to reload.
a transaction, correctly:
const data = await fetch('/api/orders').then(r => r.json()); // async work FIRST
const tx = db.transaction('orders', 'readwrite');
const store = tx.objectStore('orders');
for (const o of data) store.put(o); // all requests, no await between
await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = () => rej(tx.error); });
wrappers like idb make the requests promise-shaped; they cannot make the auto-commit rule go away.Cache API and the offline contract
The Cache API stores Request to Response pairs, keyed by URL (and optionally Vary headers). It was built for service workers to answer fetch events, and it is the half of "offline" that holds the bytes; IndexedDB holds the data.
- caches.open(name) returns a Cache. Names are how you version:
static-v12, and activate deletes everything not in the current set. - cache.add(url) / addAll([...]) fetch and store. addAll is atomic: one 404 fails the whole call, which is what you want for precaching a shell (a half shell is worse than none).
- cache.put(request, response) stores a response you already have. A Response body can be read once;
response.clone()before putting if you also want to return it. - cache.match(request, { ignoreSearch, ignoreVary }) looks up.
caches.matchsearches all caches. - Opaque responses. A no-cors cross-origin fetch yields a response with status 0 you cannot inspect. It can be cached, but it may be an error, and it is charged to quota at a padded size (Chrome: ~7 MB each) to prevent size side-channels. Prefer CORS-enabled resources.
- A precached shell (HTML, CSS, JS, fonts, icons) installed with addAll. The app boots from cache with no network.
- Data in IndexedDB, written by the app when online, read by the app when not.
- A write queue. Mutations made offline stored as records with an idempotency key, replayed in order when connectivity returns, with conflict handling (server timestamp, version, or last-writer-wins by choice). Background Sync can trigger the replay, where supported.
- An honest UI. "Saved locally, will sync" is a state the interface must show. Pretending a queued write is complete is how users lose data.
- A versioning story. New shell versions install in the background; the user is told an update is ready; applying it reloads. Never mix assets from two versions in one session.
OPFS and the File System Access API
- What. A file system private to the origin, invisible to the user as files, counted under the origin quota.
navigator.storage.getDirectory()returns the root; from there,getFileHandle,getDirectoryHandle,createWritable. - The fast path. In a dedicated worker,
handle.createSyncAccessHandle()gives synchronous read, write, truncate, flush on a file. No structured clone, no transactions, byte-level random access. This is what makes SQLite compiled to WebAssembly fast enough to be a real database in the browser (sqlite-wasm with the OPFS VFS; DuckDB-Wasm; the storage layer under several "local-first" products). - When. Data that is naturally file-shaped (documents, media), or when you want a real relational database with SQL rather than IndexedDB's key-value model, or when IndexedDB's clone cost dominates.
- Caveats. Sync handles are exclusive: one open handle per file. Safari supports it since 2023. Quota applies, and eviction applies.
- What. Access to the user's real files, with permission.
showOpenFilePicker,showSaveFilePicker,showDirectoryPickerreturn handles;handle.getFile(),handle.createWritable()read and write. Handles can be stored in IndexedDB and re-permissioned later. - When. An editor that should open and save the user's actual files, where "download a copy" is not acceptable. Photo editors, code editors, note apps.
- Support. Chromium only for the pickers and writes; Safari and Firefox have OPFS but not user-file access. Feature-detect and fall back to
<input type=file>plus download.
Quota, persistence, eviction and partitioning
Everything above lives under a quota that the browser manages, can evict when the disk is full, and partitions by who is embedding whom. A storage design that ignores these three is a design that loses data in the field.
- Ask.
navigator.storage.estimate():{ usage, quota, usageDetails }. Quota is an upper bound and may shrink as the disk fills. - Chrome. Per origin, up to 60% of disk; all origins together up to 80%. Firefox. Per group, up to 10% of disk or 10 GB, whichever is smaller, with a larger persistent allowance. Safari. Around 1 GB per origin, then a prompt; much stricter historically in private browsing.
- Exceeding it. QuotaExceededError on the write. Handle it: evict your own old data, tell the user, or degrade.
- Best-effort (default). Under pressure the browser deletes whole origins, LRU first. Safari additionally deletes storage for sites not interacted with in seven days under Intelligent Tracking Prevention, in some cases.
- Persistent.
navigator.storage.persist()requests exemption. Chrome grants silently based on engagement signals (installed PWA, bookmark, notification permission, frequent use) and otherwise returns false. Firefox prompts the user.navigator.storage.persisted()reports current state. - User clearing. "Clear site data" removes everything regardless. There is no exemption from the user.
- Storage is keyed by (top-level site, origin) in Chrome, Firefox and Safari now. Your origin embedded in site A has different IndexedDB, caches, localStorage and (unless Partitioned or SameSite=None with Storage Access) cookies than when embedded in site B or opened directly.
- Consequence. An embedded widget cannot share state across host sites through storage. Designs that assumed a shared third-party jar (SSO via iframe, cross-site carts) need the Storage Access API (
document.requestStorageAccess(), user gesture, often a prompt) or a different architecture.
Choosing a storage layer: a decision procedure
- Does the server need to read it on every request? Cookie. Nothing else, and keep it to an identifier; the state lives server-side.
- Is it under a kilobyte, read once at startup, not secret? localStorage. Stop here.
- Is it HTTP responses you want to serve offline? Cache API, driven by a service worker with a named strategy per route.
- Is it records: things with ids, that you query, update, and sync? IndexedDB, behind a thin promise wrapper, with a schema version and a migration function per version.
- Is it a file, or do you want SQL, or is IndexedDB's clone cost the bottleneck? OPFS, with sqlite-wasm if you want a database, in a worker.
- Does the user need it as a real file on their disk? File System Access API with a download fallback.
- Handle QuotaExceededError as a normal branch.
- Call persist() if the data matters, and surface the answer.
- Version your schema and your caches, and write the migration before you need it.
- Assume partitioning: state does not cross top-level sites.
- Put a size on everything you store and an eviction rule of your own, before the browser applies its rule to the whole origin.
Reading storage in DevTools
| Where | Shows | Use it for |
|---|---|---|
| Application → Storage (top) | Usage per type, quota, clear site data with checkboxes | How much the origin holds; simulating a fresh user; "Simulate custom storage quota" to test QuotaExceededError |
| Application → Cookies | Every cookie with all attributes, partition key, priority | Checking Secure/HttpOnly/SameSite; seeing which third-party cookies are blocked and why (the info icon) |
| Application → Local / Session storage | Keys, values, sizes per origin | Finding the 300 KB JSON someone stored; editing values live |
| Application → IndexedDB | Databases, stores, indexes, records; refresh; delete | Inspecting schema and records; confirming an upgrade ran; checking the version |
| Application → Cache storage | Each named cache with its request/response entries and sizes | Seeing what the SW precached; whether a response is opaque |
| Application → Storage → "Origin private file system" | OPFS tree (newer Chrome) | Inspecting files sqlite-wasm wrote |
| Network → Cookies tab on a request | Request and response cookies, with blocked ones and the reason | "Why was my cookie not sent": SameSite, Secure, domain mismatch, each named |
| Issues panel | Cookie warnings: SameSite defaults, third-party blocking | Catching cookie misconfiguration before users do |
- Route
/storage/localstorage-cost: a page that stores 4 MB of JSON in localStorage and reads it at startup. Record a Performance trace; find the synchronous block at the top. - Route
/storage/idb-await-trap: a transaction with an await in the middle. Watch TransactionInactiveError in the console; move the await and watch it pass. - Route
/storage/cookie-matrix: set cookies with each SameSite value, then trigger a same-site navigation, a cross-site link, and a cross-site fetch. Read the Network → Cookies tab for each. - Route
/storage/quota: set a custom quota of 10 MB in the Application panel and write until it fails. See which error, and that the transaction aborted.