April 29, 2026 · 7 min read

Browser storage quotas explained: IndexedDB, LocalStorage, Cache, and Cookies

How much data can you actually store in the browser? A clear-eyed breakdown of per-origin quotas across IndexedDB, LocalStorage, SessionStorage, Cookies, and Cache Storage — with the gotchas that bite local-first apps.

The short answer

On a modern Chromium browser with default settings, an origin can generally store somewhere between a few hundred megabytes and tens of gigabytes of data, depending on free disk space. That budget is shared across IndexedDB, Cache Storage, File System Access (OPFS), and a few smaller buckets. LocalStorage and Cookies are governed by separate, much smaller per-origin caps. SessionStorage is its own thing.

The browsers don’t publish a single hard number on purpose — quotas are a function of available disk and the quota algorithm — but the bands below are what you can actually plan around.

Per-storage quota table (Chromium)

StoragePer-origin capPersistenceEviction
IndexedDBShared with Cache + OPFS, up to ~60% of free diskBest-effort by default; persistent with permissionLRU when global quota under pressure
Cache StorageShared bucket with IndexedDBBest-effortLRU eviction
LocalStorage~5 MB (string keys + values)Until origin data is clearedThrows QuotaExceededError when full
SessionStorage~5 MB per tabTab lifetimeCleared on tab close
Cookies~180 cookies/origin, each up to 4 KBBy Expires / Max-AgeBy expiry or browser cleanup
OPFSSame shared bucket as IndexedDBBest-effortLRU

The exact numbers vary across Chrome major versions and operating systems — Firefox and Safari publish different ones again. Treat the table as planning data, not contract.

How to actually measure it

Modern browsers expose the runtime numbers through the navigator.storage.estimate() API:

const { quota, usage, usageDetails } = await navigator.storage.estimate();
console.log({
  quotaMB: Math.round(quota / 1_000_000),
  usageMB: Math.round(usage / 1_000_000),
  details: usageDetails, // { indexedDB, caches, serviceWorkerRegistrations, ... }
});

Run that on the inspected page from DevTools. usageDetailsis the most actionable field — it splits the consumption between IndexedDB, Cache Storage, and a few smaller buckets so you can see where a leak is actually accumulating.

Persistent vs best-effort storage

Best-effort storage can be evicted without warning if the browser’s global storage budget gets tight (low disk, aggressive history cleanup, etc.). Persistent storage cannot be evicted automatically — only the user can clear it. To request it:

const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
  const granted = await navigator.storage.persist();
  // `granted` is true if the browser decides your origin is "important
  // enough" — heuristics include: installed PWA, frequent visits,
  // bookmarked, granted notifications.
}

Local-first apps and offline-capable PWAs should ask for persistence early. The browser will say no most of the time on first visit; ask again after the user has demonstrated intent (logged in, saved something, installed the app).

The five gotchas that bite local-first apps

1. LocalStorage is synchronous

Every localStorage.setItem call blocks the main thread. Apps that fan out lots of small writes (config, preferences, recent items) feel snappy until the value crosses ~100 KB; then frame drops appear. If you find yourself writing JSON-stringified objects to LocalStorage, you almost certainly want IndexedDB instead.

2. The 5 MB LocalStorage cap is on the <key, value> string total

That includes UTF-16 encoding overhead, so the real ceiling for ASCII payloads is closer to 2.5 MB worth of source content. Hit it once and every subsequent write throws.

3. Cookies count toward request size

Origins that pile cookies on can blow past server-side header limits. Most CDNs cap inbound headers at 8–32 KB. A handful of bloated cookies will produce 431 / 494 errors that look like networking bugs but are actually storage bugs.

4. Cache Storage isn’t free

Service workers that cache aggressively (precache + runtime) can chew through the shared IndexedDB quota. If a user reports their IndexedDB writes failing intermittently, look at Cache Storage size first.

5. Eviction is silent

Best-effort eviction doesn’t fire an event. Your code finds out when a read returns no row. Defensive design: treat any read path as “maybe the data is gone,” not “the data must be there.”

Inspecting all of it at once

The numbers from navigator.storage.estimate() are aggregate. To break them down by store and quickly spot out-of-control growth, you want a tool that surfaces per-store sizes and lets you query each surface in one place.

That’s the IdxBeaveroverview view: total storage, per-database row counts, top stores by row count, LocalStorage size in bytes, and Cache Storage entry counts — alongside the same Mongo-style query interface that lets you find the row that’s probably leaking.


← All posts