The Storage Landscape: localStorage to OPFS
Your App Needs Memory
Every non-trivial web app stores data on the client. User preferences, auth tokens, cached API responses, offline drafts, entire databases. But here is the thing most developers get wrong: they reach for localStorage every time, like it is the only tool in the box. It is not. The browser gives you at least six distinct storage mechanisms, each with wildly different performance, capacity, and persistence characteristics. Pick the wrong one and you get 5MB hard caps, synchronous main-thread blocking, or data that vanishes after seven days on Safari.
This topic is your map. By the end, you will know exactly which storage API to reach for in every scenario — and more importantly, why.
Think of browser storage like a building with different floors. Cookies are the lobby mailbox — tiny, visible to everyone (the server sees them), and strictly limited. localStorage/sessionStorage are a small locker on the ground floor — convenient but cramped (5MB) and blocking (synchronous). IndexedDB is the warehouse in the basement — massive capacity, structured data, but you need to learn the forklift (async API). Cache API is the loading dock — optimized for HTTP request/response pairs, works hand-in-hand with service workers. OPFS is the private underground vault — raw file access, fastest I/O, invisible to the user. Each floor serves a purpose. Using the lobby mailbox to store a database is a bad idea.
The Six Storage APIs
1. Cookies
The oldest storage mechanism on the web. Cookies were designed for server communication, not client-side storage. Every cookie is sent with every HTTP request to the matching domain. That alone should tell you they are not for storing application data.
document.cookie = "theme=dark; max-age=31536000; path=/; SameSite=Lax";
const cookies = document.cookie; // "theme=dark; lang=en" — a raw string, not an object
Limits: 4KB per cookie, ~50 cookies per domain (browser-dependent). Total cookie storage is roughly 200KB.
Use for: Authentication tokens (HttpOnly, Secure, SameSite), server-side session IDs, feature flags that the server needs to read on every request.
Never use for: Application state, user preferences, cached data — anything that does not need to travel to the server.
2. Web Storage: localStorage and sessionStorage
The go-to for simple key-value pairs. Both APIs are synchronous and store strings only.
localStorage.setItem("user_prefs", JSON.stringify({ theme: "dark", lang: "en" }));
const prefs = JSON.parse(localStorage.getItem("user_prefs") || "{}");
sessionStorage.setItem("form_draft", JSON.stringify(formData));
localStorage persists until explicitly cleared. sessionStorage is scoped to the browser tab and cleared when the tab closes.
Limits: ~5MB per origin (combined for localStorage and sessionStorage). This is a hard cap — no way to request more.
The synchronous problem: Both APIs block the main thread. On a page with 4MB of localStorage data, getItem can block for several milliseconds — enough to cause jank during a frame.
// This blocks the main thread until the read completes
const data = localStorage.getItem("large_dataset"); // 2MB string → blocks ~3-5ms
JSON.parse(data); // another ~5ms for a large object
Use for: Small, simple preferences (theme, language, sidebar collapsed state). Data under 100KB that you read infrequently.
Never use for: Anything over 1MB, data you read on every frame, structured/queryable data, or anything that needs to work in a Web Worker (localStorage is main-thread only).
3. IndexedDB
The browser's built-in database. IndexedDB stores structured data — objects, arrays, blobs, files, ArrayBuffers — with indexes for fast queries. It is asynchronous, transactional, and available in Workers.
const request = indexedDB.open("myApp", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const store = db.createObjectStore("products", { keyPath: "id" });
store.createIndex("category", "category", { unique: false });
};
request.onsuccess = (event) => {
const db = event.target.result;
const tx = db.transaction("products", "readwrite");
tx.objectStore("products").put({ id: 1, name: "Widget", category: "tools" });
};
Limits: Up to 60% of available disk space in Chrome, up to 50% of free disk space in Firefox (capped at 2GB per origin), and up to 60% of total disk in Safari (with a caveat — Safari evicts data after 7 days of no user interaction when ITP is enabled).
Use for: Offline data, cached API responses, large datasets, anything structured that you need to query. This is your default for anything beyond simple preferences.
4. Cache API
Designed specifically for caching HTTP request/response pairs. Tied tightly to service workers, but also available on the main thread.
const cache = await caches.open("api-v1");
await cache.put("/api/products", new Response(JSON.stringify(products)));
const cached = await cache.match("/api/products");
const data = await cached.json();
Limits: Shares the same quota as IndexedDB (they draw from the same storage pool under the Storage API).
Use for: Offline-first network strategies (cache-first, stale-while-revalidate), precaching app shell resources, caching API responses for offline access.
Never use for: Arbitrary key-value storage (use IndexedDB), or anything that is not conceptually an HTTP request/response pair.
5. Origin Private File System (OPFS)
The newest and fastest storage API. OPFS gives you a private, sandboxed file system scoped to your origin. The user cannot see these files in their file explorer. The killer feature: synchronous, high-performance file access in Web Workers via createSyncAccessHandle.
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle("data.bin", { create: true });
// In a Worker — synchronous access, no async overhead
const accessHandle = await fileHandle.createSyncAccessHandle();
const buffer = new ArrayBuffer(1024);
accessHandle.read(buffer, { at: 0 });
accessHandle.write(new Uint8Array([1, 2, 3]), { at: 0 });
accessHandle.flush();
accessHandle.close();
Limits: Same quota pool as IndexedDB and Cache API. Performance is 3-4x faster than IndexedDB for large binary operations.
Browser support: Available in Chrome 102+, Firefox 111+, Safari 15.2+ (with full support including createSyncAccessHandle in Safari 17.4+, shipped March 2024).
Use for: SQLite-in-the-browser (via WASM), large binary file processing, high-performance data access in Workers, any scenario where IndexedDB's async transaction overhead is a bottleneck.
6. Cookies vs Web Storage vs IndexedDB vs Cache API vs OPFS
| Feature | Cookies | localStorage | IndexedDB | Cache API | OPFS |
|---|---|---|---|---|---|
| Capacity | ~4KB/cookie | ~5MB | Up to 60% disk | Shared quota | Shared quota |
| API | Sync (string) | Sync (string) | Async (structured) | Async (Request/Response) | Async + Sync in Workers |
| Available in Workers | No | No | Yes | Yes | Yes |
| Data types | Strings | Strings | Objects, Blobs, ArrayBuffer | Request/Response | Raw bytes |
| Queryable | No | No | Yes (indexes) | By URL | No (raw files) |
| Sent to server | Yes (every request) | No | No | No | No |
| Transactional | No | No | Yes | No | No |
| Best for | Auth tokens | Simple prefs | App data/offline | HTTP caching | WASM/binary I/O |
Storage Quotas and Eviction
All modern browsers implement the Storage API specification for managing quotas. IndexedDB, Cache API, and OPFS all draw from the same storage pool.
const estimate = await navigator.storage.estimate();
console.log(`Used: ${(estimate.usage / 1024 / 1024).toFixed(1)} MB`);
console.log(`Quota: ${(estimate.quota / 1024 / 1024).toFixed(1)} MB`);
console.log(`Remaining: ${((estimate.quota - estimate.usage) / 1024 / 1024).toFixed(1)} MB`);
Quota Limits by Browser
| Browser | Default Quota | Notes |
|---|---|---|
| Chrome | 60% of total disk size | Reports this via navigator.storage.estimate() |
| Firefox | 50% of free disk, max 2GB per eTLD+1 | Groups subdomains together |
| Safari | ~60% of total disk (macOS), ~1GB (iOS) | Evicts after 7 days without user interaction (ITP) |
Persistent Storage
By default, browser storage is "best effort" — the browser can evict it under storage pressure. You can request persistent storage to opt out of automatic eviction:
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const granted = await navigator.storage.persist();
console.log(`Persistent storage ${granted ? "granted" : "denied"}`);
}
Chrome grants persistent storage automatically if the site is bookmarked, has high engagement, or has push notification permission. Firefox shows a permission prompt. Safari does not support the persist() API.
The Decision Tree
When choosing a storage API, ask these questions in order:
- Does the server need this data on every request? → Cookie (but keep it tiny)
- Is it a simple preference under 1KB? → localStorage
- Is it an HTTP response you want to cache for offline? → Cache API
- Is it structured data you need to query? → IndexedDB
- Is it large binary data or do you need synchronous Worker access? → OPFS
- Do you need a full relational database with SQL? → SQLite compiled to WASM, backed by OPFS
SQLite in the Browser via WASM
The official SQLite project ships a WASM build that runs in the browser. Combined with OPFS for persistence, you get a full SQL database in the browser with ACID transactions, complex queries, and excellent performance. The opfs-sahpool VFS (Virtual File System) in SQLite 3.43+ provides the best performance by using OPFS synchronous access handles in a Worker.
Other notable projects: wa-sqlite supports IndexedDB and OPFS backends with optional JSPI instead of Asyncify for better performance. cr-sqlite adds CRDT-based conflict resolution for multi-device sync. PowerSync provides a managed sync layer on top of SQLite WASM for offline-first apps.
This is not a toy. Adobe Photoshop on the Web uses SQLite WASM backed by OPFS for its document storage.
Production Scenario: Choosing Storage for a Note-Taking PWA
You are building a note-taking app that works offline. Notes contain text (up to 100KB each), images (up to 5MB each), and metadata (tags, timestamps). Users have up to 10,000 notes. Here is how you split the storage:
| Data | Storage API | Why |
|---|---|---|
| Auth token | Cookie (HttpOnly, Secure) | Server needs it on every request |
| Theme preference | localStorage | Tiny, read once on load |
| Note metadata | IndexedDB | Structured, queryable by tags/date |
| Note text content | IndexedDB | Needs full-text search via indexes |
| Note images | Cache API or OPFS | Binary blobs, large, read sequentially |
| Full-text search index | OPFS + SQLite WASM | Complex queries need SQL, OPFS gives best perf |
| App shell (HTML/CSS/JS) | Cache API | Service worker precaching for offline |
| What developers do | What they should do |
|---|---|
| Storing everything in localStorage because the API is simple localStorage is synchronous (blocks the main thread), limited to 5MB, stores only strings (requiring JSON.stringify/parse overhead), is unavailable in Workers, and cannot be queried. At scale, it becomes a performance liability. | Use localStorage only for tiny preferences under 1KB. Use IndexedDB for application data |
| Ignoring Safari's 7-day ITP eviction policy Safari evicts all script-created data from origins without user interaction in 7 days. If your app relies on persistent client data without server backup, Safari users will lose everything after a week away. | Design for data loss on Safari — use server sync as the source of truth, treat client storage as a cache |
| Using IndexedDB for HTTP response caching instead of Cache API Cache API matches requests by URL, handles headers and status codes, and works naturally with fetch events in service workers. Recreating this in IndexedDB means writing your own cache matching logic for no benefit. | Use Cache API for Request/Response pairs — it is purpose-built for this and integrates with service workers |
| Not checking available quota before writing large data Writing 500MB without checking quota leads to unhandled errors. Always estimate available space first, warn users when storage is low, and implement graceful degradation when writes fail. | Use navigator.storage.estimate() to check available space and handle QuotaExceededError gracefully |
Challenge: Storage API Selection
Try to solve it before peeking at the answer.
// You are architecting storage for a music streaming PWA.
// Requirements:
// 1. Users can download songs (3-10MB each, up to 500 songs)
// 2. Playlist metadata (names, song order, cover art URLs)
// 3. Playback position for resume (current song, timestamp)
// 4. User's EQ settings (bass, treble, balance — 3 numbers)
// 5. Offline playback must work without network
// 6. App must work on Safari iOS
//
// For each requirement, pick the best storage API and explain why.
// Consider Safari's ITP 7-day eviction and total storage quota.Key Rules
- 1localStorage and sessionStorage are synchronous and block the main thread. Never store more than ~100KB in them. Never read from them on every frame.
- 2IndexedDB is your default for application data — structured, async, transactional, available in Workers, with large quota.
- 3Cache API is for HTTP request/response pairs only. Use it with service workers for offline-first network strategies.
- 4OPFS provides the fastest storage I/O (3-4x faster than IndexedDB) with synchronous access in Workers. Use it for SQLite WASM, binary processing, and high-performance scenarios.
- 5All quota-managed storage (IndexedDB, Cache API, OPFS) shares the same pool — typically 60% of disk on Chrome, 50% on Firefox, and a smaller limit on Safari iOS.
- 6Safari evicts script-created data after 7 days without user interaction (ITP). Always design for data loss — treat client storage as a cache, not a source of truth.
- 7Always call navigator.storage.estimate() before large writes and handle QuotaExceededError gracefully.