Browser Storage Fundamentals & Quotas

Modern web applications require predictable, resilient data persistence across sessions, network transitions, and device restarts. For frontend engineers and PWA developers building offline-first architectures, selecting the correct storage primitive and managing finite browser quotas are foundational engineering tasks. This guide details production-ready patterns for state persistence, quota monitoring, and graceful degradation across modern browser engines. It is the entry point to a family of deeper engineering guides — once you have the primitives in hand, the advanced storage engine patterns in IndexedDB Architecture & Advanced Patterns and the durability work in Offline Sync Strategies & Background Workflows build directly on the concepts below, and Storage Security, Encryption & Privacy covers the threat model for everything you persist.

Browser storage primitives by capacity and access model A diagram mapping the main browser storage APIs onto synchronous versus asynchronous access and their relative capacity, leading to a quota and eviction layer. Synchronous localStorage ~5 MB · main thread sessionStorage ~5 MB · per tab Asynchronous IndexedDB large · structured Cache API Response objects OPFS file handles · fast Governed by Quota + eviction estimate() · persist() Choose by data shape, then handle the quota Async by default · serialize deliberately · degrade gracefully

The storage API landscape

Before choosing a primitive, it helps to see the whole field at once. The browser exposes six distinct persistence surfaces, and each occupies a different point on the trade-off between capacity, access model, and the kind of data it stores well. The table below is the decision baseline; the sections that follow expand each row into production patterns.

API Access model Typical capacity Data shape Best for
localStorage Synchronous ~5 MB / origin String key-value Small flags, theme, last-route
sessionStorage Synchronous ~5 MB / tab String key-value Per-tab transient state
IndexedDB Asynchronous Large (quota-bound) Structured objects, blobs Queryable offline datasets
Cache API Asynchronous Large (quota-bound) Request/Response pairs Static assets, offline shell
OPFS Asynchronous Large (quota-bound) Binary file handles Large files, SQLite-in-browser
Cookies Synchronous (header) ~4 KB / cookie String, sent to server Session refs, server-read state

The two synchronous Web Storage APIs are covered in depth in Understanding Web Storage APIs, which establishes the architectural baseline for evaluating synchronous stores against asynchronous databases. The lifecycle distinction between the two — whether a value survives tab closure or a full browser restart — is the subject of localStorage vs sessionStorage. For structured data, binary blobs, and large datasets, IndexedDB is the workhorse; for file-oriented binary workloads where streaming and random access matter, the Origin Private File System (OPFS) offers a faster, lower-overhead path.

Core Storage Mechanisms & API Selection

Synchronous vs Asynchronous Storage Patterns

The browser exposes multiple storage primitives, each with distinct performance characteristics and concurrency models. Synchronous APIs like localStorage and sessionStorage execute immediately on the main thread, which can introduce measurable jank during heavy serialization or large payload writes. Asynchronous APIs, primarily IndexedDB, offload I/O to background threads and expose promise-based or callback-driven interfaces. When architecting offline-first state, developers must weigh latency against capacity. For transient UI state, understanding the lifecycle differences in localStorage vs sessionStorage dictates whether data survives tab closure or browser restarts. Production systems should default to asynchronous APIs to avoid main-thread blocking and ensure scalable read/write throughput.

A practical rule of thumb: any write whose serialized payload could exceed a few kilobytes, or any read that sits on a hot path such as a scroll handler or animation frame, belongs on an asynchronous API. Synchronous storage is acceptable only for tiny, infrequently-touched values where the convenience of a one-line getItem/setItem outweighs the main-thread cost. The moment a feature starts storing collections, binary data, or anything that needs querying, the answer is IndexedDB.

Choosing the Right API for State Persistence

API selection should be driven by data shape, access patterns, and offline requirements. Key-value stores excel at caching configuration flags or user preferences, while relational or document-oriented stores handle complex, queryable state. Refer to Understanding Web Storage APIs for an architectural baseline when evaluating synchronous stores against asynchronous databases. For offline-first applications, IndexedDB remains the only viable option for storing structured data, binary blobs, and large datasets exceeding the ~5 MB synchronous threshold. Always pair storage selection with explicit transaction scoping to prevent partial writes and ensure atomic state updates.

When the data is fundamentally file-shaped — large media, generated documents, a bundled SQLite database, or anything you want to stream rather than load whole — the Origin Private File System (OPFS) is purpose-built for it and avoids the per-record overhead of an object store. For a side-by-side decision on where multi-megabyte binaries should live, the guide on OPFS vs IndexedDB for Large Binary Files lays out the latency and memory trade-offs. Static, immutable assets — your app shell, fonts, and bundles — belong instead in the Cache API for Static Assets, which stores them as full Response objects ready to serve from a service worker.

Storage Quotas, Limits & Eviction Policies

Calculating Available Storage

Browsers allocate storage dynamically based on device capacity, origin trust levels, and user engagement. Hard limits are rarely static; instead, they fluctuate with available disk space and OS-level memory pressure. Engineers must query navigator.storage.estimate() proactively to monitor usage before writes fail. The following production-ready utility safely retrieves quota metrics across environments:

async function checkStorageQuota(): Promise<{ used: number; quota: number; available: number }> {
  if (!navigator.storage?.estimate) {
    return { available: 0, used: 0, quota: 0 };
  }
  try {
    const estimate = await navigator.storage.estimate();
    const used = estimate.usage ?? 0;
    const quota = estimate.quota ?? 0;
    return {
      used,
      quota,
      available: quota - used,
    };
  } catch (err) {
    console.error('Storage estimate unavailable:', err);
    return { used: 0, quota: 0, available: 0 };
  }
}

The number estimate() returns is a budget shared across IndexedDB, the Cache API, OPFS, and Web Storage for the origin — it is not a per-API limit. On most desktop browsers the budget is a percentage of free disk (often up to ~60%), while mobile engines apply tighter, more volatile caps. Because the figure shifts with free disk space and engagement, treat it as a live signal sampled before large writes, not a constant you cache at startup.

Handling Quota Exceeded Errors Gracefully

When available approaches zero, applications must implement defensive write strategies. Understanding Storage Quotas & Eviction Policies is critical for implementing LRU cleanup routines, requesting persistent storage permissions via navigator.storage.persist(), and designing graceful degradation paths. Mobile web teams must account for aggressive OS-level cache purging and temporary storage volatility on iOS Safari and Android WebView, where background tabs may be terminated and associated storage reclaimed without warning. Implement quota-aware middleware that intercepts QuotaExceededError, triggers cache pruning, and retries writes with exponential backoff.

The synchronous variant of this problem — a localStorage.setItem that throws the moment the ~5 MB ceiling is hit — has its own dedicated treatment in How to Handle localStorage QuotaExceededError. For the concrete numbers that differ engine to engine, Browser Storage Limits Across Chrome, Firefox, and Safari is the reference. Eviction is not always an error you catch — sometimes data simply vanishes between sessions, which is why Debugging Storage Eviction in Progressive Web Apps and the iOS Safari 7-Day Storage Eviction Workaround exist as separate deep dives.

Requesting Persistent Storage

Calling navigator.storage.persist() asks the browser to exempt your origin from best-effort eviction, moving it from the default “best-effort” bucket to a “persistent” one that is only cleared by explicit user action. Whether the request is granted depends on engagement signals — installed PWAs, bookmarked sites, and origins with high interaction are far more likely to succeed. Always check navigator.storage.persisted() first and treat a denial as expected rather than exceptional:

async function ensurePersistent(): Promise<boolean> {
  if (!navigator.storage?.persist) return false;
  if (await navigator.storage.persisted()) return true;
  const granted = await navigator.storage.persist();
  if (!granted) {
    console.info('Persistent storage denied — data may be evicted under pressure.');
  }
  return granted;
}

Production-Ready Data Handling & Serialization

Structuring State for Offline Resilience

Raw JavaScript objects cannot be stored directly in most browser APIs without transformation. The Structured Clone Algorithm governs what can be persisted, rejecting functions, DOM nodes, and circular references. Efficient Data Serialization & Deserialization prevents structured cloning errors, reduces payload size, and enables schema versioning. Implement explicit type guards and validation layers before persistence. For complex offline state, maintain a versioned schema registry and write migration routines that transform legacy payloads during database upgrades.

A crucial subtlety is that IndexedDB and the Cache API store objects via the structured clone algorithm directly, while Web Storage forces everything through a JSON.stringify/JSON.parse round-trip. Those two paths preserve different things: structured clone keeps Date, Map, Set, ArrayBuffer, and Blob intact, whereas JSON flattens them and silently drops undefined. The trade-offs are dissected in structuredClone vs JSON.stringify for IndexedDB, and the patterns for cramming nested objects into a string slot appear in Best Practices for Serializing Complex Objects in sessionStorage.

Async/Await Workflows for Read/Write Operations

Transactional async/await wrappers should batch writes, validate payloads before persistence, and handle concurrent access without race conditions or deadlocks. The following pattern demonstrates a resilient write operation with quota handling and progressive fallback, using the idb library for its promise-friendly tx.done API:

import { openDB } from 'idb';

const memoryStore = new Map<string, unknown>();

async function persistState(key: string, value: unknown): Promise<void> {
  try {
    const db = await openDB('app-db', 1, {
      upgrade(db) {
        db.createObjectStore('state-store', { keyPath: 'key' });
      },
    });
    const tx = db.transaction('state-store', 'readwrite');
    await tx.store.put({ key, value, ts: Date.now() });
    await tx.done;
  } catch (e) {
    if (e instanceof DOMException && e.name === 'QuotaExceededError') {
      await evictOldestEntries();
      await persistState(key, value);
    } else {
      try {
        localStorage.setItem(key, JSON.stringify(value));
      } catch (lsErr) {
        console.warn('Storage exhausted, using in-memory fallback');
        memoryStore.set(key, value);
      }
    }
  }
}

The idb wrapper used above is a thin promise adapter over the native IndexedDB API; you can write the same logic against the raw request-and-event interface, but the promise surface makes transaction lifetimes far easier to reason about. For the deeper transaction mechanics — scope, auto-commit timing, and deadlock avoidance — the IndexedDB Architecture & Advanced Patterns guide is the authoritative reference, and it covers the subtle IndexedDB Transaction Auto-Commit Timing Bug that bites teams who await non-IndexedDB work mid-transaction.

Cross-Tab Coordination & Concurrency

A frequently overlooked dimension of browser storage is that the same origin can run in many tabs at once, each with its own JavaScript context but sharing the same underlying IndexedDB and OPFS data. Without coordination, two tabs can race on the same write, double-submit a queued sync, or corrupt a multi-step migration. The Web Locks API for Cross-Tab Coordination provides a clean primitive for this: a named lock that only one tab holds at a time, with the lock released automatically when the holding context goes away.

async function withExclusiveSync(run: () => Promise<void>): Promise<void> {
  if (!navigator.locks) {
    // No Web Locks support — fall back to a best-effort single attempt.
    await run();
    return;
  }
  await navigator.locks.request('sync-queue', { mode: 'exclusive' }, async () => {
    await run();
  });
}

This pattern is the foundation for safe background work: the concrete case of stopping two tabs from flushing the same outbox is covered in Preventing Duplicate Sync with Web Locks. When the coordinated work is network synchronization rather than a local write, it connects directly to the queueing and retry machinery in Offline Sync Strategies & Background Workflows.

Cross-Browser Compatibility & Fallback Strategies

Testing Storage Across Mobile & Desktop Engines

Engine-specific quirks in WebKit, Chromium, and Gecko require defensive coding and rigorous testing matrices. Private browsing modes frequently restrict IndexedDB access or enforce ephemeral storage lifecycles. Always test storage operations in incognito windows, low-memory device emulators, and backgrounded tab states to surface platform-specific eviction triggers. The matrix below summarizes the support and caveats engineers most often trip over.

Capability Chrome / Edge Firefox Safari (incl. iOS)
IndexedDB Full Full Full, but evicted after 7 days inactivity
Cache API Full Full Full; counts against shared quota
OPFS Full Full Supported (16.4+); sync access in Workers
Web Locks API Full Full Supported (15.4+)
navigator.storage.persist() Honored on engagement Honored Limited; ITP overrides
Private mode IndexedDB Works, ephemeral Works, ephemeral Historically blocked, now ephemeral

Implementing Progressive Fallback Chains

When primary databases fail due to quota exhaustion or engine restrictions, chain progressive fallbacks to lighter APIs or in-memory stores. Decouple application logic from network-dependent routing by pairing state persistence with the Cache API for Static Assets to ensure resilient offline bootstrapping. Implement a storage abstraction layer that attempts IndexedDB first, falls back to localStorage for small payloads, and defaults to an in-memory Map with explicit TTL expiration when disk I/O is unavailable. This ensures core application functionality remains intact regardless of storage constraints. For the specific question of whether a given payload belongs in the Cache API or an object store, see When to Use the Cache API over IndexedDB and IndexedDB vs Cache API for Offline JSON Payloads.

Common Engineering Pitfalls & Debugging Workflows

Blocking the Main Thread

Synchronous storage calls remain a primary source of main-thread jank, particularly on low-end mobile devices. Avoid localStorage reads/writes inside animation frames, scroll handlers, or critical rendering paths. Profile storage operations using the Performance tab and defer non-critical persistence to requestIdleCallback or Web Workers. Because OPFS exposes a synchronous access handle inside Web Workers, large file I/O can be moved entirely off the main thread without blocking the UI — a pattern that pairs well with offloading serialization work too.

Silent Failures & Unhandled Promise Rejections

Ignoring QuotaExceededError and assuming infinite browser storage leads to corrupted offline state. Always wrap storage operations in try/catch blocks with explicit error logging and user-facing fallbacks. Implement global unhandledrejection listeners to catch async transaction failures that bypass local error handling. Ensure partial write failures trigger explicit transaction rollback logic to maintain data consistency.

Memory Leaks from Unbounded Caching

Failing to implement versioned migrations for IndexedDB schema changes or storing unserialized DOM nodes and circular references causes rapid memory bloat. Implement DevTools workflows to simulate quota exhaustion via the Application panel, trace async transaction lifecycles, audit memory retention across navigation events, and verify service worker cache synchronization. Regularly profile heap snapshots to identify detached nodes or orphaned database connections. Pair automated quota monitoring with telemetry alerts to detect storage degradation in production before it impacts offline sync reliability.

Treating Storage as a Security Boundary

A final, pervasive mistake is assuming that data on the device is private or trustworthy. Every Web Storage and IndexedDB value is readable by any script on the origin and sits in plaintext on disk. Auth tokens, personal data, and cached authorized responses all need an explicit protection strategy rather than a default trust in the store — the full threat model and the encryption, token-handling, and partitioning mitigations are the subject of Storage Security, Encryption & Privacy.

Quick-reference summary

Decision Default choice Escalate to
Tiny non-sensitive flag localStorage
Per-tab transient state sessionStorage
Structured queryable data IndexedDB Compound indexes, cursors
Large binary / files OPFS Streaming, Worker sync access
Static assets / app shell Cache API Service worker strategies
Multi-tab safe writes Web Locks API Background sync queue
Sensitive payloads Encrypted IndexedDB Web Crypto, HttpOnly cookies

Frequently Asked Questions

What is the real storage limit in a browser?

There is no single fixed number. localStorage and sessionStorage cap at roughly 5 MB per origin, but IndexedDB, the Cache API, and OPFS share a dynamic quota that is typically a percentage of free disk space and shifts with available storage and user engagement. Always read navigator.storage.estimate() at runtime rather than assuming a constant. See Storage Quotas & Eviction Policies for the mechanics.

Should I use localStorage or IndexedDB?

Use localStorage only for small, non-sensitive, infrequently-accessed string values where a synchronous one-liner is convenient. For anything that involves collections, binary data, querying, or payloads beyond a few kilobytes, use IndexedDB so the work stays off the main thread. The lifecycle and capacity contrast is detailed in localStorage vs sessionStorage.

How do I stop the browser from evicting my offline data?

Call navigator.storage.persist() to request the persistent bucket, which is exempt from best-effort eviction. Grants depend on engagement — installed PWAs and bookmarked origins succeed more often. Even when granted, Safari’s Intelligent Tracking Prevention can still clear storage after 7 days of inactivity, so design recovery around a server-side refresh. The workaround is covered in Debugging Storage Eviction in Progressive Web Apps.

Why do my objects come back wrong after storing them?

Web Storage forces values through JSON.stringify, which drops undefined, functions, and class instances and turns Date objects into strings. IndexedDB and the Cache API use the structured clone algorithm, which preserves Date, Map, Set, and binary types but still rejects functions and DOM nodes. Pick the right path for your data shape — see Data Serialization & Deserialization.

How do I keep two open tabs from corrupting shared storage?

Coordinate writes with the Web Locks API for Cross-Tab Coordination. Request a named exclusive lock around any multi-step write or sync flush so only one tab runs it at a time; the lock releases automatically if that tab closes. This prevents duplicate submissions and half-applied migrations across tabs.

Related