← Back to the library
FrontendHow it works · Applied · 13 min

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

Storage APIs at a glanceCapacity is per origin. Everything below cookies and Web Storage shares the same origin quota.
CapacitySync or asyncData typesIn workersSurvives
CookiesAbout 4 KB per cookie; per-domain count limitdocument.cookie is syncStringsNot via document.cookie; sent with fetchUntil Expires/Max-Age; JS-set cookies capped at 7 days in Safari
localStorageAbout 5 MiB per originSynchronous; blocks main threadStrings only (JSON yourself)NoRestarts; evicted with origin; Safari 7-day cap applies
sessionStorageAbout 5 MiB per originSynchronousStrings onlyNoReloads in the same tab; gone when the tab closes
IndexedDBShared origin quota (GBs)Async, transactionalStructured-clonable values, Blobs, indexesYesRestarts; evicted with origin unless persisted
Cache APIShared origin quotaAsync (promises)Request to Response pairsYes, including service workersRestarts; ignores HTTP expiry, you delete entries
OPFSShared origin quotaAsync; sync access handles in dedicated workersFiles and raw bytesYes; sync handles only in dedicated workersRestarts; evicted with origin; invisible to the user
Quota and eviction by browserNumbers from MDN and the WebKit blog as of October 2026. Quotas are shares of total disk; estimate() reports what applies to you.
Default quotaEvictionpersist()Notable quirks
Chrome / EdgeUp to 60% of disk per origin; 80% for all originsLRU, whole origin, best-effort onlyGranted or denied silently by engagement, install, bookmark, notificationsIncognito has a much smaller quota and is wiped on close; Storage Buckets API
FirefoxBest-effort: min(10% of disk, 10 GiB per site group)LRU, whole origin, best-effort onlyShows a permission promptPersisted 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 ruleHeuristic, no promptScript-writable storage deleted after 7 days of use without interaction
Safari web app / WebKit appsHome Screen web app: browser quota; other WebKit apps 15%Own days-of-use counter; not on Safari's clockMore likely granted for Home Screen web appsStorage 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.
localStorage documentation (opens in new tab)

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.
IndexedDB + idb 8 documentation (opens in new tab)

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.
Cache API (window or worker) documentation (opens in new tab)

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.
OPFS sync access handle documentation (opens in new tab)

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).
StorageManager documentation (opens in new tab)

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.

SOURCES

Explainer reviewed