Browser Storage APIs: localStorage, IndexedDB, and Beyond
A deep dive into browser-side persistence, examining the design trade-offs behind each storage API, their quota models, transaction semantics, and eviction behavior. The WHATWG Storage Standard (Living Standard) unifies quota management under a single bucket model, while individual APIs—Web Storage (localStorage/sessionStorage), IndexedDB (W3C, version 3.0), and the Cache API (W3C)—each optimize for different access patterns. Choosing the right API depends on data shape, access frequency, thread requirements, and durability guarantees—not just capacity limits.
Abstract
Browser storage is not one system—it’s five APIs with different serialization models, threading guarantees, and eviction behaviors, all sharing a per-origin quota.
Core mental model:
- localStorage/sessionStorage store DOMString key-value pairs synchronously on the main thread—fast for small reads, dangerous for large data. localStorage persists across sessions; sessionStorage dies with the tab
- IndexedDB is an asynchronous, transactional object store using the structured clone algorithm—handles complex objects and binary data at scale, but its transaction model has subtle lifetime rules that break naively async code
- Cache API stores
Request→Responsepairs and is optimized for service worker integration (covered in depth in the Service Workers and Cache API article) - Origin Private File System (OPFS) provides raw file system access with synchronous I/O in workers—the fastest storage option for compute-heavy workloads
- Quota is per-origin and varies dramatically by browser (Chrome: ~60% of disk, Firefox: up to 2 GiB per group, Safari: 1 GiB with 7-day eviction). All script-writable storage shares this budget
The Storage Landscape
Choosing the Right API
| Criteria | localStorage | sessionStorage | IndexedDB | Cache API | OPFS |
|---|---|---|---|---|---|
| Data model | String KV | String KV | Structured objects | Request/Response | Raw bytes |
| Capacity | ~5 MiB | ~5 MiB | Origin quota (GBs) | Origin quota (GBs) | Origin quota (GBs) |
| Threading | Sync, blocks main thread | Sync, blocks main thread | Async (event/promise) | Async (promise) | Sync in workers only |
| Persistence | Cross-session | Tab lifetime | Cross-session | Cross-session | Cross-session |
| Indexing | Key only | Key only | Multi-column indexes | URL matching | None |
| Use case | User prefs, tokens | Wizard state, form drafts | App data, offline DB | HTTP response cache | SQLite, Wasm state |
Design insight: The split between synchronous and asynchronous APIs reflects a fundamental tension. Web Storage (localStorage/sessionStorage) was designed in 2009 for simple needs—synchronous access made the API trivial to use. But synchronous storage on the main thread doesn’t scale. IndexedDB (first spec 2011, current version 3.0) was designed as the scalable replacement, trading simplicity for async transactions, structured data, and indexing.
When Cookies Still Win
Storage APIs don’t replace cookies for all use cases:
- Authentication tokens:
HttpOnlycookies can’t be read by JavaScript, preventing XSS (Cross-Site Scripting) token theft. localStorage tokens are always vulnerable - Server-side access: Cookies are sent with every HTTP request; storage APIs are client-only
- Expiration control:
ExpiresandMax-Ageprovide server-controlled TTL (Time to Live). Storage APIs have no built-in expiration - Security attributes:
Secure,SameSite, andHttpOnlyhave no storage API equivalents
Web Storage: localStorage and sessionStorage
The Web Storage specification (WHATWG HTML Standard) defines two Storage objects that share an identical interface but differ in lifetime and scope.
Interface and Serialization
Both APIs expose the same Storage interface:
// All values are coerced to DOMStringlocalStorage.setItem("count", "42") // StorelocalStorage.getItem("count") // "42" (always a string)localStorage.removeItem("count") // Delete single keylocalStorage.clear() // Delete all keys for this originlocalStorage.key(0) // Get key name by indexlocalStorage.length // Number of stored pairs
// Property-style access also works (but setItem/getItem is preferred)localStorage.username = "alice" // Same as setItem("username", "alice")delete localStorage.username // Same as removeItem("username")Serialization trap: Every value is coerced to a string via toString(). Objects become "[object Object]" unless explicitly serialized:
// ❌ Silent data losslocalStorage.setItem("user", { name: "Alice" })localStorage.getItem("user") // "[object Object]"
// ✅ Explicit serializationlocalStorage.setItem("user", JSON.stringify({ name: "Alice" }))JSON.parse(localStorage.getItem("user")!) // { name: "Alice" }
// ⚠️ JSON.parse(null) returns null, but JSON.parse("undefined") throwsconst value = localStorage.getItem("missing") // nullJSON.parse(value) // null (safe)localStorage vs sessionStorage
| Behavior | localStorage | sessionStorage |
|---|---|---|
| Lifetime | Persists until explicitly deleted or evicted | Deleted when tab/window closes |
| Scope | Shared across all same-origin tabs/windows | Isolated per tab (including duplicated tabs) |
| Cross-tab visibility | Yes (via storage events) | No |
| Restored on tab restore | N/A (always available) | Yes—browser restores sessionStorage on tab restore |
| Capacity | ~5 MiB per origin | ~5 MiB per origin |
Design reasoning: sessionStorage exists because localStorage’s cross-tab sharing creates problems for multi-step workflows. A shopping cart checkout in two tabs would share state via localStorage, causing race conditions. sessionStorage provides tab-isolated state. The WHATWG spec notes sessionStorage is “intended to allow separate instances of the same web application to run in different windows without interfering with each other.”
The Synchronous Problem
Web Storage is synchronous and blocks the main thread. Every getItem/setItem call performs I/O on the UI thread:
// ⚠️ Blocks UI during operation// For small data (< 100 keys, simple values), this is imperceptiblelocalStorage.setItem("pref", "dark")
// ❌ Blocking with large data causes jank// 5 MiB of JSON parsing on the main threadconst bigData = JSON.parse(localStorage.getItem("cache")!)// User cannot scroll, click, or interact during this operationWhy synchronous? The spec was written in 2009 when storage needs were simpler and the main thread was less contended. The synchronous API is also why the spec recommends a conservative 5 MiB limit—larger quotas would make blocking worse. The spec literally warns: “User agents should limit the total amount of space allowed for storage areas.”
Storage Events: Cross-Tab Communication
When localStorage changes, the browser fires a storage event on every other same-origin window—never on the originating tab:
// Tab A: writes datalocalStorage.setItem("theme", "dark")// No storage event fires in Tab A
// Tab B: receives the changewindow.addEventListener("storage", (event) => { // event.key - "theme" // event.oldValue - "light" (previous value, or null) // event.newValue - "dark" (new value, or null if removed) // event.url - URL of the tab that made the change // event.storageArea - reference to localStorage or sessionStorage
if (event.key === "theme") { applyTheme(event.newValue) }})7 collapsed lines
// Cross-tab messaging pattern: broadcast via localStoragefunction broadcast(channel: string, data: unknown) { localStorage.setItem(`__msg_${channel}`, JSON.stringify({ data, timestamp: Date.now() })) // Clean up to avoid filling storage localStorage.removeItem(`__msg_${channel}`)}Edge case: The storage event fires even when newValue === oldValue—setting a key to its current value still triggers events on other tabs. The event fires after the storage area changes, so by the time a listener runs, localStorage.getItem(event.key) may already reflect a subsequent change.
BroadcastChannel alternative: For cross-tab messaging without touching storage, use
BroadcastChannel. It doesn’t persist data and avoids I/O overhead. localStorage storage events are a legacy workaround from before BroadcastChannel existed.
Edge Cases and Failure Modes
QuotaExceededError: Thrown when setItem() exceeds the ~5 MiB limit. The spec says: “If it couldn’t set the new value, the method must throw a ‘QuotaExceededError’ DOMException.”
try { localStorage.setItem("key", largeValue)} catch (e) { if (e instanceof DOMException && e.name === "QuotaExceededError") { // Storage full—evict old entries or warn user }}Private browsing: In all modern browsers, localStorage works in private/incognito mode but data is ephemeral—deleted when the private window closes. Safari previously threw QuotaExceededError on any setItem in private mode (fixed in Safari 11+).
Disabled storage: Users can disable web storage entirely. Feature detection is required:
function isStorageAvailable(): boolean { try { const test = "__storage_test__" localStorage.setItem(test, test) localStorage.removeItem(test) return true } catch { return false }}5 MiB is in UTF-16 code units: The storage limit is typically measured in UTF-16 code units (2 bytes each), so 5 MiB allows ~2.5 million characters. Non-BMP characters consume two code units each.
IndexedDB: Transactional Object Store
IndexedDB (W3C, version 3.0) is an asynchronous, transactional, indexed object store designed for structured data at scale. It uses the structured clone algorithm for serialization, supports multi-column indexes, and provides ACID-like (Atomicity, Consistency, Isolation, Durability) transactions within a single origin.
Data Model
- Database: Named container with a version number. Multiple databases per origin are allowed
- Object store: Named collection of records (analogous to a table). Records are key-value pairs where values are structured-cloneable objects
- Index: Secondary key path into an object store, enabling efficient queries on non-primary-key fields
- Key: Every record has a key—either an explicit
keyPathproperty, an out-of-line key, or an auto-incrementing key generator
Valid key types (from the spec, in sort order): numbers (except NaN), Date objects (except invalid), strings, ArrayBuffer/typed arrays, and arrays (which sort element-by-element, enabling compound keys).
Database Versioning and Schema Upgrades
IndexedDB uses an integer version scheme. Schema changes (creating/deleting object stores and indexes) can only happen inside an upgradeneeded event:
3 collapsed lines
// Helper for promisifying (collapsed)function openDB(name: string, version: number): Promise<IDBDatabase> { return new Promise((resolve, reject) => { const request = indexedDB.open("myapp", 3)
request.onupgradeneeded = (event) => { const db = request.result const oldVersion = event.oldVersion
// Incremental migrations based on old version if (oldVersion < 1) { const users = db.createObjectStore("users", { keyPath: "id" }) users.createIndex("by-email", "email", { unique: true }) } if (oldVersion < 2) { const orders = db.createObjectStore("orders", { keyPath: "id", autoIncrement: true, }) orders.createIndex("by-date", "createdAt") } if (oldVersion < 3) { // Add index to existing store const users = request.transaction!.objectStore("users") users.createIndex("by-role", "role") } }
5 collapsed lines
// Resolve/reject handlers (collapsed) request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) })}Design reasoning: The version-based upgrade mechanism exists because IndexedDB is a client-side database—you can’t run migrations on all clients simultaneously like a server DB. Each client may be at any historical version. The upgradeneeded event fires with oldVersion so you can apply incremental migrations. The versionchange transaction has exclusive access to the database, preventing concurrent schema modifications.
The blocked / versionchange Problem
Version upgrades require exclusive database access. If other tabs have open connections, the upgrade can’t proceed:
// Tab A: has an open connectionconst db = await openDB("myapp", 2)
// Tab B: tries to upgrade to version 3const request = indexedDB.open("myapp", 3)
// Step 1: Tab A receives versionchange eventdb.onversionchange = () => { db.close() // Must close to unblock Tab B // Optionally: alert user to reload}
// Step 2: If Tab A doesn't close, Tab B receives blocked eventrequest.onblocked = () => { // Upgrade can't proceed until Tab A closes its connection console.warn("Database upgrade blocked by another tab")}This is a common production bug: If you don’t handle onversionchange, your app silently blocks other tabs from upgrading. Always close the database on versionchange.
Transactions
IndexedDB provides three transaction modes:
| Mode | Concurrent Access | Object Store Access | Use Case |
|---|---|---|---|
readonly | Multiple concurrent | Read only | Queries, reporting |
readwrite | Exclusive per store | Read + write | Mutations |
versionchange | Exclusive (entire DB) | Schema changes + R/W | Upgrades only |
3 collapsed lines
// Assume db is already opened (collapsed)const db = await openDB("myapp", 1)
const tx = db.transaction(["users", "orders"], "readwrite")const users = tx.objectStore("users")const orders = tx.objectStore("orders")
// All operations within this transaction are atomicawait wrapRequest(users.put({ id: "u1", name: "Alice", role: "admin" }))await wrapRequest(orders.add({ userId: "u1", item: "Widget", createdAt: new Date() }))
tx.oncomplete = () => console.log("Transaction committed")tx.onerror = () => console.error("Transaction failed:", tx.error)tx.onabort = () => console.warn("Transaction aborted")Transaction Lifetime: The Critical Gotcha
Transactions auto-commit when the event loop returns to its idle state after all pending requests are resolved. This means inserting any async operation that yields to the event loop (like fetch, setTimeout, or await-ing a non-IDB promise) will cause the transaction to become inactive:
const tx = db.transaction("users", "readwrite")const store = tx.objectStore("users")
// ✅ Works: back-to-back IDB operations keep transaction activestore.put({ id: "1", name: "Alice" })store.put({ id: "2", name: "Bob" })
// ❌ Breaks: fetch yields to the event loop, transaction becomes inactivestore.put({ id: "1", name: "Alice" })const data = await fetch("/api/user/2") // Transaction dies herestore.put(await data.json()) // TransactionInactiveErrorThe spec says: A transaction is active when it’s first created, becomes inactive when “control returns to the event loop”, and reactivates when a success/error event fires for one of its requests. Once all requests complete and control returns to the event loop with no pending requests, the transaction auto-commits.
Fix: Gather all data before starting the transaction, or use separate transactions:
// ✅ Fetch first, then transactconst data = await fetch("/api/users").then((r) => r.json())
const tx = db.transaction("users", "readwrite")const store = tx.objectStore("users")for (const user of data) { store.put(user)}Durability Hints
IndexedDB 3.0 introduced durability hints via the durability option on transactions:
// "relaxed" (default in Chrome 121+, Firefox 40+, Safari): OS may buffer writesconst tx = db.transaction("users", "readwrite", { durability: "relaxed" })
// "strict": flush to persistent storage before reporting completeconst tx2 = db.transaction("users", "readwrite", { durability: "strict" })Performance impact: Relaxed durability provides 3–30x throughput improvement by deferring OS buffer flushes. The trade-off: data written in a relaxed transaction may be lost if the OS crashes (not just the browser—power failure or kernel panic). Browser crashes and tab crashes are still safe because the browser process commits to its WAL (Write-Ahead Log) before reporting success.
Design reasoning: Most web apps don’t need strict durability—the data is a cache of server state. Relaxed durability matches what users expect: if the power goes out, losing the last few seconds of client-side data is acceptable. strict matters for apps where the browser is the primary data store (offline-first productivity tools, local databases).
Structured Clone: What Can Be Stored
IndexedDB serializes values using the structured clone algorithm, which supports more types than JSON:
Cloneable (stored correctly):
- Primitives (string, number, boolean, null, undefined, BigInt)
- Date, RegExp (except
lastIndex) - ArrayBuffer, typed arrays, DataView
- Blob, File, FileList
- ImageBitmap, ImageData
- Map, Set
- Array, plain objects (own enumerable properties)
Not cloneable (throws DataCloneError):
- Functions and closures
- DOM nodes
- Symbols
- Property descriptors (getters, setters)
- Prototype chains (class instances lose their prototype)
- Error objects (in some older browsers)
// ✅ Rich objects survive structured clonestore.put({ id: "1", created: new Date(), // Date preserved (JSON would stringify) tags: new Set(["a", "b"]), // Set preserved (JSON would lose it) binary: new Uint8Array([1, 2]), // Binary preserved (JSON can't do this)})
// ❌ Class instances lose their prototypeclass User { greet() { return "hi" }}store.put({ id: "1", user: new User() })// Retrieved object has no greet() method—it's a plain objectIndexes and Querying
Indexes enable efficient lookups on non-primary-key fields:
5 collapsed lines
// Schema setup (collapsed)const store = db.createObjectStore("products", { keyPath: "id" })store.createIndex("by-category", "category")store.createIndex("by-price", "price")store.createIndex("by-cat-price", ["category", "price"]) // Compound index
// Query by index using key rangesconst tx = db.transaction("products", "readonly")const index = tx.objectStore("products").index("by-price")
// Exact matchconst request = index.get(29.99)
// Range queries with IDBKeyRangeindex.getAll(IDBKeyRange.bound(10, 50)) // 10 ≤ price ≤ 50index.getAll(IDBKeyRange.bound(10, 50, true, false)) // 10 < price ≤ 50index.getAll(IDBKeyRange.lowerBound(100)) // price ≥ 100index.getAll(IDBKeyRange.upperBound(20)) // price ≤ 20
// Compound index query: category="electronics" AND price 50-200const compoundIndex = tx.objectStore("products").index("by-cat-price")compoundIndex.getAll(IDBKeyRange.bound(["electronics", 50], ["electronics", 200]))Cursor-based iteration for large result sets:
const tx = db.transaction("products", "readonly")const index = tx.objectStore("products").index("by-price")
const cursor = index.openCursor(IDBKeyRange.lowerBound(50), "next")
cursor.onsuccess = () => { const result = cursor.result if (result) { processProduct(result.value) result.continue() // Move to next record }}Edge Cases and Failure Modes
Storage corruption: Can occur from repeated clearing during active I/O operations, or from structured clone failures on upgrade. Recovery requires deleting and rebuilding the database:
const deleteRequest = indexedDB.deleteDatabase("myapp")deleteRequest.onsuccess = () => { // Recreate from server or defaults}deleteRequest.onblocked = () => { // Other tabs still have open connections}QuotaExceededError in IndexedDB: Unlike localStorage, this can happen during any write operation—including during transactions. A failed write aborts the entire transaction:
tx.onerror = (event) => { if (tx.error?.name === "QuotaExceededError") { // Entire transaction was rolled back // Evict old data and retry }}Private browsing limits: Chrome limits IndexedDB to ~32 MiB per database and ~500 MiB total in incognito. All data is deleted when the incognito window closes.
IDB on the main thread vs workers: IndexedDB is available in web workers (including service workers and shared workers), which avoids main-thread I/O entirely. For large writes, always use a worker:
// Heavy IndexedDB writes in a web worker—zero main thread impactself.onmessage = async (event) => { const db = await openDB("bulk", 1) const tx = db.transaction("data", "readwrite") for (const record of event.data.records) { tx.objectStore("data").put(record) } tx.oncomplete = () => self.postMessage({ status: "done" })}Origin Private File System (OPFS)
The Origin Private File System (WHATWG File System Standard) provides a sandboxed file system per origin with synchronous access in workers—the fastest storage API available in browsers.
Why OPFS Exists
IndexedDB’s structured clone overhead and transaction model add latency that matters for compute-heavy workloads. OPFS provides raw byte access:
- SQLite in the browser: Projects like sql.js and the official SQLite Wasm build use OPFS as their backing store
- Wasm state persistence: Emscripten and other Wasm toolchains map OPFS to a virtual filesystem
- Large binary data: Image/video processing pipelines that need direct byte access without serialization overhead
API Surface
OPFS has two access modes:
// Async access (available on main thread and workers)const root = await navigator.storage.getDirectory()const fileHandle = await root.getFileHandle("data.bin", { create: true })
// Async read/write via File and WritableStreamconst file = await fileHandle.getFile() // Returns a File (Blob subclass)const writable = await fileHandle.createWritable()await writable.write(new Uint8Array([1, 2, 3]))await writable.close()// Synchronous access (workers only)—MUCH fasterconst root = await navigator.storage.getDirectory()const fileHandle = await root.getFileHandle("data.bin", { create: true })
const syncHandle = await fileHandle.createSyncAccessHandle()
// Direct byte operations—no promises, no structured cloneconst buffer = new ArrayBuffer(1024)syncHandle.read(buffer, { at: 0 }) // Read 1024 bytes from offset 0syncHandle.write(new Uint8Array([1, 2, 3]), { at: 0 })syncHandle.flush() // Ensure data is written to disksyncHandle.close() // Release the lockDesign reasoning: createSyncAccessHandle() is restricted to workers because synchronous I/O on the main thread would block user interaction—the same problem that makes large localStorage operations problematic. By limiting sync access to workers, the spec provides the performance of synchronous I/O without the main-thread penalty.
Limitations: OPFS files are invisible to the user (no file picker), not shareable across origins, and count against the same origin quota as IndexedDB and Cache API.
Storage Quotas and Eviction
The WHATWG Storage Standard defines a unified quota model for all script-writable storage (IndexedDB, Cache API, OPFS). Web Storage (localStorage/sessionStorage) has its own separate ~5 MiB limit.
Quota by Browser
| Browser | Origin Quota | Eviction Trigger | Notes |
|---|---|---|---|
| Chrome | ~60% of total disk | Storage pressure | Calculated as 80% disk × 75% per origin. Static—doesn’t consider free space (anti-fingerprinting) |
| Firefox | Up to 2 GiB per eTLD+1 group | Global limit: 50% of free disk | Group limit is min(20% of global, 2 GiB). Minimum 10 MiB per group |
| Safari | 1 GiB initially | Prompts user for more on desktop | Safari 17+: up to 80% in browser apps, 20% in WKWebView |
The StorageManager API
// Check current usageconst estimate = await navigator.storage.estimate()console.log(`Used: ${(estimate.usage! / 1024 / 1024).toFixed(1)} MiB`)console.log(`Quota: ${(estimate.quota! / 1024 / 1024).toFixed(0)} MiB`)console.log(`Available: ${((estimate.quota! - estimate.usage!) / 1024 / 1024).toFixed(0)} MiB`)
// Request persistent storage (prevents eviction under pressure)const persisted = await navigator.storage.persist()if (persisted) { console.log("Storage marked persistent—won't be evicted automatically")} else { console.log("Browser denied persistence request")}
// Check persistence statusconst isPersisted = await navigator.storage.persisted()Persistent vs best-effort: By default, all storage is “best-effort”—the browser can evict it under storage pressure without asking the user. Calling navigator.storage.persist() requests “persistent” mode, which requires user approval (explicit or implicit) before the browser can delete the data. Chrome auto-grants persistence for installed PWAs (Progressive Web Apps) and sites with high engagement; Firefox shows a permission prompt; Safari does not support the persistence API.
Safari’s 7-Day Eviction (ITP)
Since Safari 13.1 (March 2020), Intelligent Tracking Prevention (ITP) deletes all script-writable storage after 7 days without user interaction for a given origin. This affects localStorage, IndexedDB, Cache API, and service worker registrations.
What counts as “user interaction”: The user must tap or click on the site (not just visit via redirect). Navigation via window.open() or link decoration does not reset the timer.
Exempt scenarios:
- PWAs added to the home screen
- Sites the user has explicitly interacted with in the last 7 days
Impact: Any offline-first app on Safari that the user doesn’t visit weekly will lose all client-side data. Server-side persistence is mandatory for Safari users.
Storage Partitioning
Modern browsers partition storage by top-level site to prevent cross-site tracking:
Chrome (115+): Third-party storage is partitioned by the top-level site. An iframe from cdn.example.com embedded on siteA.com gets different storage than the same iframe on siteB.com. This affects IndexedDB, Cache API, localStorage, and service workers.
Firefox (103+): State Partitioning double-keys all storage by (resource origin, top-level eTLD+1). Enabled by default in Enhanced Tracking Protection.
Safari: Permanently blocks third-party storage access. The Storage Access API allows embedded contexts to request first-party storage access after a user gesture.
Practical impact: If your app uses iframes or third-party embeds that rely on shared storage, storage partitioning breaks those assumptions. Use the Storage Access API or document.requestStorageAccess() for legitimate cross-site storage needs.
Practical Patterns
Wrapper with Fallback
4 collapsed lines
// Type definitions (collapsed)type StorageValue = string | number | boolean | object | nullinterface StorageAdapter { get(key: string): Promise<StorageValue> set(key: string, value: StorageValue): Promise<void>}
// localStorage adapter with quota handlingconst localStorageAdapter: StorageAdapter = { async get(key) { const raw = localStorage.getItem(key) return raw ? JSON.parse(raw) : null }, async set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)) } catch (e) { if (e instanceof DOMException && e.name === "QuotaExceededError") { // Evict oldest entries and retry, or fall back to IndexedDB throw new Error("localStorage quota exceeded") } throw e } },5 collapsed lines
}
// Usage (collapsed)const adapter = isStorageAvailable() ? localStorageAdapter : indexedDBAdapterawait adapter.set("prefs", { theme: "dark", lang: "en" })IndexedDB Promise Wrapper
The raw IndexedDB API uses event callbacks. A thin promise wrapper reduces boilerplate:
function wrapRequest<T>(request: IDBRequest<T>): Promise<T> { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result) request.onerror = () => reject(request.error) })}
function wrapTransaction(tx: IDBTransaction): Promise<void> { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve() tx.onerror = () => reject(tx.error) tx.onabort = () => reject(tx.error || new DOMException("Transaction aborted")) })}
// Usageconst tx = db.transaction("users", "readwrite")tx.objectStore("users").put({ id: "1", name: "Alice" })await wrapTransaction(tx) // Resolves when transaction commitsConsider libraries: For production use, idb by Jake Archibald provides a well-tested promise wrapper. Dexie.js adds query builder syntax and live queries. These eliminate the boilerplate without hiding important semantics.
Cache Invalidation with Version Keys
// Simple TTL-based invalidation for localStoragefunction setWithTTL(key: string, value: unknown, ttlMs: number) { const entry = { value, expiry: Date.now() + ttlMs } localStorage.setItem(key, JSON.stringify(entry))}
function getWithTTL<T>(key: string): T | null { const raw = localStorage.getItem(key) if (!raw) return null
const entry = JSON.parse(raw) if (Date.now() > entry.expiry) { localStorage.removeItem(key) return null } return entry.value}Conclusion
Browser storage APIs form a spectrum from simple-but-blocking (localStorage) to powerful-but-complex (IndexedDB) to raw-but-fast (OPFS). The right choice depends on your data shape, access patterns, and threading requirements—not just capacity.
localStorage works for small, string-serializable preferences that need cross-tab visibility. IndexedDB handles structured app data at scale, but its transaction lifetime rules, version upgrade protocol, and blocked/versionchange coordination require careful implementation. OPFS enables performance-critical workloads like client-side SQLite. The Cache API bridges service worker caching (covered in the companion article).
The quota model is the unifying constraint: all script-writable storage (except Web Storage’s separate ~5 MiB) shares a per-origin budget that varies dramatically by browser. Safari’s 7-day ITP eviction makes server-side persistence non-optional for any serious app. Storage partitioning changes the rules for third-party contexts. Design for eviction, not just persistence.
Appendix
Prerequisites
- JavaScript Promises and async/await
- Basic understanding of the same-origin policy
- Familiarity with JSON serialization
Terminology
- Origin: Scheme + host + port tuple (e.g.,
https://example.com:443). Storage is scoped per origin - eTLD+1: Effective Top-Level Domain plus one label (e.g.,
example.comforsub.example.com). Used for quota grouping in Firefox - Structured clone: Serialization algorithm that supports more types than JSON (Date, Map, Set, ArrayBuffer, Blob). Used by IndexedDB and
postMessage() - WAL (Write-Ahead Log): Journaling strategy where changes are written to a log before applying to the main data file. Used by IndexedDB implementations for crash recovery
- OPFS: Origin Private File System—a sandboxed, per-origin virtual file system with synchronous access in workers
- ITP: Intelligent Tracking Prevention—Safari’s privacy feature that restricts cross-site tracking and evicts storage after 7 days without interaction
- best-effort storage: Default storage mode where the browser can evict data under storage pressure without user consent
- persistent storage: Storage mode (requested via
navigator.storage.persist()) where user consent is required before eviction
Summary
- localStorage is synchronous, blocks the main thread, stores DOMString KV pairs, has a ~5 MiB limit, and enables cross-tab communication via
storageevents - sessionStorage shares the same API but is scoped to a single tab and dies when the tab closes
- IndexedDB 3.0 provides async transactional storage with structured clone serialization, multi-column indexes, and cursor-based iteration. Transactions auto-commit when the event loop is idle—inserting non-IDB async operations kills them
- OPFS offers the fastest storage via synchronous
FileSystemSyncAccessHandlein workers—ideal for SQLite/Wasm workloads - Quota is per-origin: Chrome ~60% of disk, Firefox up to 2 GiB per group, Safari 1 GiB with 7-day ITP eviction.
navigator.storage.persist()requests eviction protection - Storage partitioning (Chrome 115+, Firefox 103+, Safari) isolates third-party storage by top-level site, breaking cross-site storage sharing
References
Specifications (Primary Sources)
- Storage Standard - WHATWG Living Standard (quota model, storage buckets, persistence)
- Web Storage - HTML Standard - WHATWG (localStorage, sessionStorage)
- Indexed Database API 3.0 - W3C (transactions, object stores, indexes, versioning)
- File System Standard - WHATWG (OPFS, FileSystemSyncAccessHandle)
- Service Worker Specification - W3C (Cache interface, CacheStorage)
Official Documentation
- Storage quotas and eviction criteria - MDN
- IndexedDB API - MDN
- Web Storage API - MDN
- Origin Private File System - MDN
- Structured clone algorithm - MDN
- Storage Access API - MDN
Core Maintainer Content and Technical Resources
- Storage for the Web - web.dev - Chrome DevRel overview of storage APIs
- IndexedDB Durability Mode Now Defaults to Relaxed - Chrome Blog
- Storage Partitioning - Chrome Privacy Sandbox
- Updates to Storage Policy - WebKit Blog
- Full Third-Party Cookie Blocking and More - WebKit Blog - Safari ITP storage cap details
- State Partitioning - MDN - Firefox storage partitioning
- SQLite Wasm - Official SQLite WebAssembly build using OPFS