Skip to content

The Storage Landscape: localStorage to OPFS

advanced18 min read

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.

Mental Model

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.

Quiz
A developer stores a user's shopping cart (2KB JSON) in a cookie. What happens on every subsequent HTTP request to the same domain?

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).

Quiz
Can you access localStorage from inside a Web Worker?

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

FeatureCookieslocalStorageIndexedDBCache APIOPFS
Capacity~4KB/cookie~5MBUp to 60% diskShared quotaShared quota
APISync (string)Sync (string)Async (structured)Async (Request/Response)Async + Sync in Workers
Available in WorkersNoNoYesYesYes
Data typesStringsStringsObjects, Blobs, ArrayBufferRequest/ResponseRaw bytes
QueryableNoNoYes (indexes)By URLNo (raw files)
Sent to serverYes (every request)NoNoNoNo
TransactionalNoNoYesNoNo
Best forAuth tokensSimple prefsApp data/offlineHTTP cachingWASM/binary I/O
Quiz
You need to store 50MB of product catalog data for offline access in a PWA. Users filter products by category and price. Which storage API should you use?

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

BrowserDefault QuotaNotes
Chrome60% of total disk sizeReports this via navigator.storage.estimate()
Firefox50% of free disk, max 2GB per eTLD+1Groups 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.

Quiz
A Safari user visits your PWA, stores 200MB of offline data in IndexedDB, then does not return for 10 days. What happens to the data?

The Decision Tree

When choosing a storage API, ask these questions in order:

  1. Does the server need this data on every request? → Cookie (but keep it tiny)
  2. Is it a simple preference under 1KB? → localStorage
  3. Is it an HTTP response you want to cache for offline? → Cache API
  4. Is it structured data you need to query? → IndexedDB
  5. Is it large binary data or do you need synchronous Worker access? → OPFS
  6. 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:

DataStorage APIWhy
Auth tokenCookie (HttpOnly, Secure)Server needs it on every request
Theme preferencelocalStorageTiny, read once on load
Note metadataIndexedDBStructured, queryable by tags/date
Note text contentIndexedDBNeeds full-text search via indexes
Note imagesCache API or OPFSBinary blobs, large, read sequentially
Full-text search indexOPFS + SQLite WASMComplex queries need SQL, OPFS gives best perf
App shell (HTML/CSS/JS)Cache APIService worker precaching for offline
What developers doWhat 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

Challenge: Pick the Right Storage

Try to solve it before peeking at the answer.

javascript
// 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

Key Rules
  1. 1localStorage and sessionStorage are synchronous and block the main thread. Never store more than ~100KB in them. Never read from them on every frame.
  2. 2IndexedDB is your default for application data — structured, async, transactional, available in Workers, with large quota.
  3. 3Cache API is for HTTP request/response pairs only. Use it with service workers for offline-first network strategies.
  4. 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.
  5. 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.
  6. 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.
  7. 7Always call navigator.storage.estimate() before large writes and handle QuotaExceededError gracefully.
1/5