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.
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
- Understanding Web Storage APIs — the architectural baseline for synchronous Web Storage versus asynchronous databases.
- Storage Quotas & Eviction Policies — how budgets are calculated, when eviction fires, and how to request persistence.
- Origin Private File System (OPFS) — the file-oriented store for large binaries and high-throughput I/O.
- IndexedDB Architecture & Advanced Patterns — the next step once you are storing structured, queryable data at scale.
- Storage Security, Encryption & Privacy — the threat model and protection patterns for everything you persist locally.