Browser storage: what lives where, and for how long
Cookies, localStorage, IndexedDB, Cache API, OPFS: how much can I store, how long does it survive, and how do Chrome, Firefox and Safari differ?
Browsers give each origin one quota shared by IndexedDB, the Cache API, OPFS and service worker registrations, plus small separate stores for cookies and Web Storage. All of it is best-effort by default: the browser can delete an origin's data under pressure, and Safari also deletes script-written data after 7 days of use without interaction.
THE MENTAL MODEL
Treat the browser as a cache you do not control, not a database you own. Cookies are tiny strings that ride along with every HTTP request, so they are for the server's benefit (sessions), not for app data. localStorage and sessionStorage are a small synchronous string map: easy, but they block the main thread and are unavailable in workers. Everything else (IndexedDB for structured records, the Cache API for Request/Response pairs, OPFS for files and byte-level I/O) draws from one per-origin bucket whose size is a share of disk that differs by browser. That bucket is best-effort until navigator.storage.persist() is granted, and it is evicted as a unit, least recently used origin first. Safari layers a policy on top: script-writable storage is deleted after seven days of Safari use without a user interaction on the site, unless the site runs as a Home Screen web app. Design for loss: the server or a re-sync is the source of truth, and local storage is an accelerator and an offline buffer.
HOW IT FITS TOGETHER
| Capacity | Sync or async | Data types | In workers | Survives | |
|---|---|---|---|---|---|
| Cookies | About 4 KB per cookie; per-domain count limit | document.cookie is sync | Strings | Not via document.cookie; sent with fetch | Until Expires/Max-Age; JS-set cookies capped at 7 days in Safari |
| localStorage | About 5 MiB per origin | Synchronous; blocks main thread | Strings only (JSON yourself) | No | Restarts; evicted with origin; Safari 7-day cap applies |
| sessionStorage | About 5 MiB per origin | Synchronous | Strings only | No | Reloads in the same tab; gone when the tab closes |
| IndexedDB | Shared origin quota (GBs) | Async, transactional | Structured-clonable values, Blobs, indexes | Yes | Restarts; evicted with origin unless persisted |
| Cache API | Shared origin quota | Async (promises) | Request to Response pairs | Yes, including service workers | Restarts; ignores HTTP expiry, you delete entries |
| OPFS | Shared origin quota | Async; sync access handles in dedicated workers | Files and raw bytes | Yes; sync handles only in dedicated workers | Restarts; evicted with origin; invisible to the user |
| Default quota | Eviction | persist() | Notable quirks | |
|---|---|---|---|---|
| Chrome / Edge | Up to 60% of disk per origin; 80% for all origins | LRU, whole origin, best-effort only | Granted or denied silently by engagement, install, bookmark, notifications | Incognito has a much smaller quota and is wiped on close; Storage Buckets API |
| Firefox | Best-effort: min(10% of disk, 10 GiB per site group) | LRU, whole origin, best-effort only | Shows a permission prompt | Persisted origins get 50% of disk, up to 8 TiB, outside the group limit |
| Safari (browser app) | About 60% of disk per origin, 80% overall (Safari 17+) | LRU under pressure, plus ITP 7-day rule | Heuristic, no prompt | Script-writable storage deleted after 7 days of use without interaction |
| Safari web app / WebKit apps | Home Screen web app: browser quota; other WebKit apps 15% | Own days-of-use counter; not on Safari's clock | More likely granted for Home Screen web apps | Storage is separate from Safari's; cross-origin iframes get 1/10 quota |
KEY TERMS
- One quota per origin
- IndexedDB, Cache API, OPFS and service worker registrations share a single storage bucket per origin (partitioned further by top-level site for third-party iframes). navigator.storage.estimate() returns approximate usage and quota for that bucket; the numbers are deliberately imprecise to limit fingerprinting.
- Best-effort vs persistent
- Buckets start best-effort: the browser may delete them without telling the user when space runs low. navigator.storage.persist() asks to switch to persistent, which is only cleared by the user. Firefox prompts; Chrome, Edge and Safari decide silently from signals such as engagement and installation.
- Eviction
- When the browser hits its overall limit or the device runs low, it deletes whole origins, least recently used first, skipping persistent ones. You never lose half an origin's data from eviction, but you can lose IndexedDB, caches and the service worker together.
- Safari's 7-day cap
- With tracking prevention on, Safari deletes all script-writable storage (IndexedDB, localStorage, sessionStorage, service worker registrations and caches) after seven days of Safari use with no user interaction on the site. Server-set cookies are exempt; cookies written by document.cookie are capped at a 7-day expiry. Home Screen web apps have their own days-of-use counter.
- Cookie attributes
- HttpOnly hides a cookie from JavaScript; Secure limits it to HTTPS; SameSite=Lax (the default when unset in browsers that implement it) withholds it from cross-site subrequests; SameSite=None requires Secure. The __Host- prefix requires Secure, Path=/ and no Domain. Partitioned (CHIPS) keys a third-party cookie to the top-level site; it is Baseline since December 2025.
- OPFS
- The Origin Private File System is a sandboxed file tree from navigator.storage.getDirectory(). In a dedicated worker, createSyncAccessHandle() gives exclusive, synchronous read/write/flush on a file, which is what SQLite-in-WASM builds use. Users cannot see these files.
- Private browsing and Storage Buckets
- Private modes give separate, often smaller quotas and delete everything when the session ends. Storage Buckets (navigator.storageBuckets.open, Chromium 122+) let one origin create several independently evictable buckets with their own persistence and durability; Chromium currently supports only IndexedDB in them, so feature-detect before relying on it.
IN YOUR STACK
TypeScript · localStorage Client
export function createStore<T>(key: string, version: number, fallback: T) {
return {
get(): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
const parsed = JSON.parse(raw) as { v: number; data: T };
return parsed.v === version ? parsed.data : fallback; // ignore old shapes
} catch {
return fallback; // corrupt JSON, or storage blocked (SecurityError)
}
},
set(data: T): boolean {
try {
localStorage.setItem(key, JSON.stringify({ v: version, data }));
return true;
} catch (err) {
if (err instanceof DOMException && err.name === "QuotaExceededError") return false;
throw err;
}
},
};
}
const prefs = createStore("prefs", 2, { theme: "system" });
addEventListener("storage", (e) => { if (e.key === "prefs") document.documentElement.dataset.theme = prefs.get().theme; });- Keep it to small preferences. Every call is synchronous disk-backed I/O on the main thread.
- The storage event fires in other same-origin tabs, not the one that wrote, which makes it a cheap cross-tab sync signal.
- Never store access tokens here: any XSS can read localStorage. Use an HttpOnly cookie instead.
TypeScript · IndexedDB + idb 8 Client
import { openDB, type DBSchema } from "idb";
interface AppDB extends DBSchema {
outbox: { key: string; value: { id: string; body: unknown; createdAt: number }; indexes: { byCreatedAt: number } };
}
const dbPromise = openDB<AppDB>("app", 2, {
upgrade(db, oldVersion, _newVersion, tx) {
if (oldVersion < 1) db.createObjectStore("outbox", { keyPath: "id" });
if (oldVersion < 2) tx.objectStore("outbox").createIndex("byCreatedAt", "createdAt");
},
blocking() { void dbPromise.then((db) => db.close()); }, // let another tab upgrade
});
export async function enqueue(body: unknown) {
const db = await dbPromise;
await db.put("outbox", { id: crypto.randomUUID(), body, createdAt: Date.now() });
}
export async function drain(send: (body: unknown) => Promise<void>) {
const db = await dbPromise;
for (const item of await db.getAllFromIndex("outbox", "byCreatedAt")) {
await send(item.body); // network call outside any transaction
await db.delete("outbox", item.id);
}
}- Transactions auto-commit as soon as they have no pending requests, so awaiting a fetch inside one ends it. Do I/O between transactions, as here.
- Schema changes happen only in upgrade, gated by version. A tab holding an older connection blocks the upgrade until it closes; handle blocking.
- Values go through structured clone: Dates, Maps and Blobs survive, class instances come back as plain objects.
JavaScript · Cache API (window or worker) Client
const CACHE = "avatars-v1";
export async function cachedFetch(url, maxAgeMs = 60 * 60 * 1000) {
const cache = await caches.open(CACHE);
const hit = await cache.match(url);
const age = hit ? Date.now() - Number(hit.headers.get("x-cached-at")) : Infinity;
if (hit && age < maxAgeMs) return hit;
const response = await fetch(url);
if (!response.ok) return hit ?? response; // serve stale rather than fail
const headers = new Headers(response.headers);
headers.set("x-cached-at", String(Date.now())); // Cache API has no expiry of its own
const body = await response.blob();
await cache.put(url, new Response(body, { status: response.status, headers }));
return new Response(body, { status: response.status, headers });
}
export async function dropOldCaches() {
for (const name of await caches.keys()) {
if (name !== CACHE) await caches.delete(name);
}
}- Entries never expire on their own and Cache-Control is ignored; you decide freshness and deletion, typically by versioned cache names.
- Opaque (no-cors) cross-origin responses expose no status or headers, so response.ok checks fail on them; serve those assets with CORS if you need to validate before caching.
JavaScript · OPFS sync access handle Client
// main.js
const worker = new Worker(new URL("./log.worker.js", import.meta.url), { type: "module" });
worker.postMessage({ name: "events.ndjson", line: JSON.stringify({ at: Date.now(), type: "open" }) });
// log.worker.js: sync access handles exist only in dedicated workers
const handles = new Map();
const encoder = new TextEncoder();
function handleFor(name) {
if (!handles.has(name)) {
handles.set(name, navigator.storage.getDirectory()
.then((root) => root.getFileHandle(name, { create: true }))
.then((file) => file.createSyncAccessHandle())); // exclusive lock on the file
}
return handles.get(name);
}
self.onmessage = async ({ data: { name, line } }) => {
const access = await handleFor(name);
access.write(encoder.encode(line + "\n"), { at: access.getSize() }); // synchronous
access.flush();
};- createSyncAccessHandle() is async, but read, write, getSize, truncate and flush on the handle are synchronous and fast.
- The handle holds an exclusive lock; a second createSyncAccessHandle on the same file fails until close(). Cache the promise, not the handle, to avoid racing.
JavaScript · StorageManager Client
export async function storageReport() {
if (!navigator.storage?.estimate) return null;
const { usage = 0, quota = 0 } = await navigator.storage.estimate();
return {
usedMB: Math.round(usage / 1e6),
quotaMB: Math.round(quota / 1e6),
percent: quota ? Number(((100 * usage) / quota).toFixed(1)) : 0,
persisted: await navigator.storage.persisted(),
};
}
// Ask during a meaningful action ("Save for offline"), never on page load.
export async function requestDurableStorage() {
if (await navigator.storage.persisted()) return true;
const granted = await navigator.storage.persist(); // Firefox prompts; others decide silently
if (!granted) console.info("best-effort: data may be evicted under storage pressure");
return granted;
}
document.querySelector("#save-offline")?.addEventListener("click", async () => {
await requestDurableStorage();
console.table(await storageReport());
});- estimate() is approximate on purpose. Use it to decide how much to prefetch, and still catch QuotaExceededError on every write.
- In Chrome a denial is not final; persist() is re-evaluated later against the same signals (engagement, install, bookmark, notification permission).
HTTP · Set-Cookie header Server
# Session: hidden from JS, HTTPS only, host-locked, sent on top-level navigations
Set-Cookie: __Host-session=9f2c41e7; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1209600
# CSRF token the page must read: no HttpOnly, never sent cross-site
Set-Cookie: __Host-csrf=b81e09aa; Path=/; Secure; SameSite=Strict; Max-Age=7200
# Third-party iframe widget: one cookie jar per top-level site (CHIPS)
Set-Cookie: __Host-widget=34d8g; Path=/; Secure; HttpOnly; SameSite=None; Partitioned
# Logout: same name, path and attributes, expired
Set-Cookie: __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0- __Host- guarantees Secure, Path=/ and no Domain, so a sibling subdomain cannot set or overwrite the cookie.
- Server-set cookies are exempt from Safari's 7-day script-storage deletion, which makes an HttpOnly session cookie the durable piece of web auth.
- Every cookie is sent on every matching request. Keep them small (browsers cap each at about 4 KB) and keep app data elsewhere.
WHEN TO REACH FOR WHAT
Cookies (HttpOnly)
Use when The server needs it on every request: session ids, CSRF tokens, consent flags.
Avoid when Storing app data or anything large; it bloats every request and is capped at about 4 KB per cookie.
localStorage / sessionStorage
Use when Small, non-sensitive UI preferences or per-tab drafts that are cheap to lose.
Avoid when Tokens or secrets (XSS-readable), large data, frequent writes, or code that runs in workers.
IndexedDB
Use when Structured records, offline queues, indexed queries, Blobs, anything the app reads and writes in volume.
Avoid when A few keys of settings, where the async API and version upgrades are more ceremony than value.
Cache API
Use when Storing HTTP responses for offline or fast startup, usually from a service worker.
Avoid when Data that is not naturally a Request/Response pair, or when you expect HTTP caching headers to manage expiry.
OPFS
Use when Large files, byte-range writes, or a WASM database such as SQLite that needs fast synchronous I/O in a worker.
Avoid when Files the user should see or export (use the File System Access or download APIs), or small key-value data.
Server only
Use when Data must survive device loss, Safari's 7-day rule or eviction, or must be shared across devices.
Avoid when The app must work offline or start instantly; then keep a local copy as a cache of the server.
WHERE IT BITES
- Assuming local data is permanent. Eviction, Safari's 7-day cap and "clear site data" all delete it; the server or a re-sync must be able to rebuild state.
- Keeping auth tokens in localStorage. Any injected script can read and exfiltrate them; HttpOnly cookies cannot be read by script.
- Large or frequent localStorage writes on the main thread. They are synchronous and show up as input lag on slower devices.
- Awaiting a fetch inside an IndexedDB transaction. The transaction auto-commits and the next request throws TransactionInactiveError.
- Not handling QuotaExceededError. Quotas vary by browser, device and mode, and private browsing can be far smaller than you test with.
- Testing only in a regular Chrome profile. Safari's ITP, installed web apps, Firefox's prompt and private modes all behave differently.
CLOSE THE AI. EXPLAIN THIS.
A PWA stores an offline outbox in IndexedDB. A Safari user on iOS returns after ten days, and the outbox is empty. Explain why, what would have been different if the app had been added to the Home Screen, what persist() and estimate() would have told you, and how you would redesign so nothing is lost.WHEN IT BREAKS IN PRODUCTION
SOURCES
- MDN: Storage quotas and eviction criteria (opens in new tab)Checked
- WebKit: Updates to Storage Policy (opens in new tab)Checked
- WebKit: Full Third-Party Cookie Blocking and More (opens in new tab)Checked
- web.dev: Persistent storage (opens in new tab)Checked
- Chrome for Developers: Storage Buckets (opens in new tab)Checked
- MDN: Partitioned cookies (CHIPS) (opens in new tab)Checked
Explainer reviewed