Part 8 · 9 chapters · ~60 min

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.

85

The storage map: what exists and why there are so many

the question

"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.

APISync?SizeWorkers?Sent to server?Value typeBuilt for
CookiesYes (document.cookie); async via cookieStore~4 KB each, ~180 per domainSW via cookieStoreYes, every matching requestStringSession identity, server-read state
localStorageYes~5 MBNoNoStringTiny flags: theme, dismissed banner
sessionStorageYes~5 MBNoNoStringPer-tab scratch; survives reload, not close
IndexedDBNoOrigin quota (GBs)YesNoStructured cloneRecords, offline data, queues
Cache APINoOrigin quotaYesNoResponseAssets and API responses for offline
OPFSSync in workersOrigin quotaYesNoBytesFile-shaped data; SQLite in Wasm
File System AccessNoUnlimited (user's files)Handles yesNoBytesEditing the user's real files, with permission
the rules that fall out
  1. 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.
  2. 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.
  3. Anything a worker must reach is IndexedDB, Cache API or OPFS. localStorage does not exist in workers.
  4. Anything that must survive the browser's own cleanup needs persist(), covered later.
THE STORAGE MAP
every place a page can keep bytes
swipe the figure sideways, or tap expand for full screen
1/7
axes
Two axes. Left to right: synchronous APIs that block the main thread, then asynchronous ones that do not. Bottom to top: kilobytes to gigabytes. The synchronous column is small by design; the asynchronous column is where real data goes.
86

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.

the attributes
  1. Domain. Which hosts receive it. Unset means the exact host only (no subdomains). Domain=example.com means example.com and all subdomains. A cookie cannot be set for a parent that is a public suffix (.co.uk).
  2. Path. Which URL paths. Rarely a security boundary; same-origin script can read all paths. Set it to /.
  3. Secure. Only over https. Without it, a plain-http request to the same host leaks the cookie.
  4. HttpOnly. Invisible to document.cookie and cookieStore. The reason XSS on a well-configured site cannot steal the session directly.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
worked numbers
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).
the third-party cookie situation
Safari and Firefox block third-party cookies by default; Chrome has partitioned them per top-level site in various phases and offers CHIPS as the explicit opt-in. Any design that depends on a cookie being readable across top-level sites is a design that depends on a feature being removed. Use first-party cookies per site and server-side linking, or the Storage Access API where the user has a relationship with the embed.
A COOKIE'S ATTRIBUTES AT WORK
what gets sent where
swipe the figure sideways, or tap expand for full screen
1/6
set
Set-Cookie: session=abc; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Lax. Each attribute is a filter on when the cookie is attached and who can read it.
87

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.

what it actually does
  1. 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.
  2. Write. setItem(k, v) stringifies the value, updates the in-memory copy, and asynchronously persists. The storage event fires in other same-origin documents, not the one that wrote. That event is a usable cross-tab signal.
  3. Quota. Around 5 MB per origin (10 MB in some). Exceeding it throws QuotaExceededError synchronously. Safari in private browsing historically threw on any write.
  4. 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.
  5. 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.
legitimate uses
  1. A theme preference, a dismissed-banner flag, the last-selected tab. Strings under a kilobyte, read once at startup.
  2. A cross-tab signal via the storage event (logout in one tab logs out all).
  3. 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.
the lens
Application panel → Local storage shows the keys and sizes. A key holding tens of kilobytes of JSON is a startup cost someone pays every load, and the Performance panel will show it as a long synchronous task at the top of the page's script.
88

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.

the model
  1. 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.
  2. 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 versionchange event and must close, or the upgrade blocks.
  3. 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.
  4. Requests within a transaction: get, getAll, put, add, delete, openCursor, count. Each is asynchronous, returns an IDBRequest, delivers success or error as an event.
  5. 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.
the traps
  1. 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.
  2. 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.
  3. 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.
  4. Version conflicts across tabs. Deploying a schema change while a user has an old tab open blocks the upgrade. Handle blocked and versionchange: close the old connection, tell the user to reload.
worked numbers
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.
AN INDEXEDDB TRANSACTION
open, transact, auto-commit
swipe the figure sideways, or tap expand for full screen
1/6
open
indexedDB.open("app", 3). If the stored version is lower, upgradeneeded fires first with a versionchange transaction, the only place stores and indexes can be created. Then success delivers the database.
89

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.

the API
  1. caches.open(name) returns a Cache. Names are how you version: static-v12, and activate deletes everything not in the current set.
  2. 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).
  3. 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.
  4. cache.match(request, { ignoreSearch, ignoreVary }) looks up. caches.match searches all caches.
  5. 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.
what makes "offline" actually work
  1. A precached shell (HTML, CSS, JS, fonts, icons) installed with addAll. The app boots from cache with no network.
  2. Data in IndexedDB, written by the app when online, read by the app when not.
  3. 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.
  4. 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.
  5. 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.
90

OPFS and the File System Access API

Origin Private File System
  1. 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.
  2. 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).
  3. 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.
  4. Caveats. Sync handles are exclusive: one open handle per file. Safari supports it since 2023. Quota applies, and eviction applies.
File System Access API
  1. What. Access to the user's real files, with permission. showOpenFilePicker, showSaveFilePicker, showDirectoryPicker return handles; handle.getFile(), handle.createWritable() read and write. Handles can be stored in IndexedDB and re-permissioned later.
  2. 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.
  3. 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.
91

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.

quota
  1. Ask. navigator.storage.estimate(): { usage, quota, usageDetails }. Quota is an upper bound and may shrink as the disk fills.
  2. 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.
  3. Exceeding it. QuotaExceededError on the write. Handle it: evict your own old data, tell the user, or degrade.
eviction
  1. 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.
  2. 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.
  3. User clearing. "Clear site data" removes everything regardless. There is no exemption from the user.
partitioning
  1. 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.
  2. 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.
the design rule
Client storage is a cache. It can vanish from eviction, from the user, from a browser bug, from a reinstall. Either the server holds the truth and the client resyncs, or persist() returned true and you tell the user their data lives on this device and give them an export. Nothing in between is honest.
QUOTA, PERSISTENCE AND EVICTION
what happens when the disk fills
swipe the figure sideways, or tap expand for full screen
1/6
estimate
navigator.storage.estimate() returns { usage, quota }. Quota in Chrome is up to 60% of total disk per origin, within an overall 80% budget across all origins; Safari is far smaller, around 1 GB per origin, and asks the user beyond that. Firefox sits between.
92

Choosing a storage layer: a decision procedure

ask in this order
  1. Does the server need to read it on every request? Cookie. Nothing else, and keep it to an identifier; the state lives server-side.
  2. Is it under a kilobyte, read once at startup, not secret? localStorage. Stop here.
  3. Is it HTTP responses you want to serve offline? Cache API, driven by a service worker with a named strategy per route.
  4. 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.
  5. 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.
  6. Does the user need it as a real file on their disk? File System Access API with a download fallback.
then, regardless
  1. Handle QuotaExceededError as a normal branch.
  2. Call persist() if the data matters, and surface the answer.
  3. Version your schema and your caches, and write the migration before you need it.
  4. Assume partitioning: state does not cross top-level sites.
  5. Put a size on everything you store and an eviction rule of your own, before the browser applies its rule to the whole origin.
a token in localStoragereadable by any script on the page, including a compromised dependency; sent nowhere automatically; one XSS away from session theft
a session in an HttpOnly cookieunreadable by script; sent automatically with SameSite rules; needs CSRF protection on mutations; the shape the platform designed for identity
93

Reading storage in DevTools

WhereShowsUse it for
Application → Storage (top)Usage per type, quota, clear site data with checkboxesHow much the origin holds; simulating a fresh user; "Simulate custom storage quota" to test QuotaExceededError
Application → CookiesEvery cookie with all attributes, partition key, priorityChecking Secure/HttpOnly/SameSite; seeing which third-party cookies are blocked and why (the info icon)
Application → Local / Session storageKeys, values, sizes per originFinding the 300 KB JSON someone stored; editing values live
Application → IndexedDBDatabases, stores, indexes, records; refresh; deleteInspecting schema and records; confirming an upgrade ran; checking the version
Application → Cache storageEach named cache with its request/response entries and sizesSeeing 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 requestRequest and response cookies, with blocked ones and the reason"Why was my cookie not sent": SameSite, Secure, domain mismatch, each named
Issues panelCookie warnings: SameSite defaults, third-party blockingCatching cookie misconfiguration before users do
go to the lab
  1. 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.
  2. 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.
  3. 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.
  4. 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.