IndexedDB or OPFS? Choosing Browser Storage for Large Binary Data
IndexedDB or OPFS? A working engineer's comparison of browser persistence for large binary data, with trade-offs, gotchas, and quota strategy.
Once a web application holds more than preferences—document working sets, recorded media, multi-megabyte exports waiting to be re-opened—the question stops being whether to persist locally and becomes where. The browser offers two serious answers with very different shapes: IndexedDB, a transactional object store optimized for queryable records, and the Origin Private File System, a sandboxed virtual filesystem built for raw blocks.
Our earlier piece on how browser storage actually works explains this landscape at the user level—where your work lives and why it survives. This article is the engineer’s counterpart: the mechanics under load, a measurement methodology you can run yourself, and a decision framework for choosing between them per workload rather than per fashion.
The browser storage hierarchy at a glance
Four mechanisms coexist in every modern browser, each with a distinct contract:
| Mechanism | Model | Sync? | Comfortable payload | Best at |
|---|---|---|---|---|
| localStorage | Key–value strings | Blocking | Kilobytes | Preferences, flags |
| Cache API | Request→Response pairs | Async | Asset-sized | Offline app shells |
| IndexedDB | Transactional object store | Async | Moderate blobs + many small records | Queryable state |
| OPFS | Sandboxed file tree | Async or sync-in-worker | Very large binaries | Raw document IO |
The table hides one asymmetry worth stating plainly: everything except OPFS serializes. Data crossing into IndexedDB is copied through the structured-clone algorithm—the same machinery discussed in our Web Workers deep dive—while OPFS handles operate directly against bytes on disk.
IndexedDB under load: strengths and friction
IndexedDB’s design goals are transactional integrity and queryability. Writes happen inside explicit transactions that either fully apply or fully roll back; secondary indexes let you fetch records by properties instead of scanning; cursors stream through ranges efficiently. For application state—documents’ metadata, edit histories, settings, queues—that model fits precisely, and no filesystem API competes with it there.
The friction appears when values grow. Every record that enters or leaves an object store passes through structured clone: a multi-megabyte blob costs a full copy on write and another on read, memory pressure doubles transiently, and large transactions hold their locks long enough to contend with unrelated queries. Event-based APIs add ergonomic drag on top—promises arrived late and remain unevenly shaped—which is why thin wrappers like the open-source idb library are near-universal in production code.
None of this makes IndexedDB slow for its intended job. It makes it the wrong shape for “one enormous opaque payload, accessed positionally, rewritten in place”—which is exactly the job OPFS was designed around.
OPFS: a real filesystem inside the sandbox
The Origin Private File System gives each origin a private directory tree invisible to the user and to other origins. Entry points differ by thread:
// Main thread: streaming writes into one growing OPFS file.
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle('capture.webm', { create: true });
const stream = await handle.createWritable(); // FileSystemWritableFileStream
await stream.write({ type: 'write', data: chunkBuffer });
await stream.write({ type: 'seek', position: headerOffset });
await stream.write({ type: 'write', data: patchedHeader });
await stream.close(); // atomic publish on close
createWritable() returns a writable file stream—ideal for appending chunks from recordings, downloads, or export pipelines—and writes become visible atomically on close() rather than dribbling out half-applied.
Inside a worker, the sharper tool unlocks:
// opfs-worker.js — positional random access via a synchronous handle.
const root = await navigator.storage.getDirectory();
const file = await root.getFileHandle('working-set.bin', { create: true });
const io = await file.createSyncAccessHandle(); // workers ONLY
const header = new Uint8Array(16);
io.read(header, { at: 0 }); // read at an offset
io.write(new Uint8Array([1, 2, 3, 4]), { at: 1024 }); // patch in place
io.truncate(4096); // resize without rewrite
io.flush();
io.close(); // exclusive lock released
A FileSystemSyncAccessHandle reads and writes at arbitrary offsets synchronously—no promises, no serialization layer between your loop and the bytes. That is the mechanism behind fast local databases compiled to WebAssembly (the official SQLite WASM build persists through exactly this API) and behind responsive editors that patch megabyte-scale working sets without ever rewriting them wholesale.
The trade-offs: sync handles take an exclusive lock while open, so concurrent access needs deliberate coordination, and they exist only in workers—main-thread code gets the stream flavor or nothing.
Measuring it yourself: a reproducible harness
Storage benchmarks published online age badly: they inherit the author’s hardware, browser version, payload shape, and machine state. The defensible move is running your own harness, which is smaller than it sounds:
- Fix the variables. Choose payload sizes spanning your real range—say 10, 50, and 200 MB—and realistic access mixes: sequential write, sequential full read, scattered 4 KB reads at random offsets.
- Isolate the thread. Run every test inside a dedicated worker so UI interference cannot contaminate timings, reusing one worker across trials.
- Warm up, then measure. Discard the first run per configuration (lazy initialization dominates cold runs), then time several repetitions and report medians, not bests.
- Close what you open. Release handles and complete transactions between trials; leaked locks silently serialize subsequent tests.
- Record context. Browser, version, platform, and device tier go in the results table—or the numbers mean nothing next quarter.
Run that harness against both backends and consistent qualitative orderings tend to emerge:
| Operation | Typical leader | Why |
|---|---|---|
| Sequential write, huge blob | OPFS sync handle | No clone step between JS and disk |
| Full sequential read | OPFS sync handle | Positional read straight into one buffer |
| Random 4 KB patches | OPFS sync handle | Offset seeks vs whole-record fetches |
| Small-record indexed lookup | IndexedDB | Index queries are its native model |
| Range scans over metadata | IndexedDB | Cursors stream sorted results cheaply |
Treat even those orderings as hypotheses until your own runs confirm them—driver behavior, filesystem caching, and engine releases all shift absolute numbers, occasionally dramatically.
Quotas, eviction, and persistence
Both backends draw from the same origin-wide storage pool, reported honestly by navigator.storage.estimate(): current usage, granted quota. Quotas scale with available disk and vary by browser and signal quality of the site (installed status, engagement), so treat them as weather, not constants—query before writing large payloads and degrade gracefully when refused.
Eviction follows two tiers. Best-effort storage can be reclaimed under disk pressure, typically after disuse. Requesting navigator.storage.persist() promotes your data to the protected tier, making eviction far less likely—browsers weigh the request differently (Firefox, for instance, may confirm with the user). Request persistence deliberately: hoarding the flag dilutes the signal it sends.
Private browsing modes shrink everything—in-memory quotas, ephemeral files, guaranteed erasure on close. Treat any “private window” session as disposable regardless of which backend you chose.
And one boundary no API crosses: local persistence is not backup. Disk failure, browser reset, and deliberate wipes all outrank eviction flags. Durable tools give users portable exports—as our offline diary guide argues, the export file, not the workspace, is the deliverable.
A decision framework
Collapsing the comparison into practice:
| Use case | Reach for |
|---|---|
| App state, metadata, edit history, queues | IndexedDB |
| Multi-MB documents, media working sets, export staging | OPFS + sync handles in a worker |
| Growing captures or streamed downloads | OPFS writable streams |
| Both at once | Hybrid: records in IndexedDB referencing named OPFS blobs |
The hybrid deserves emphasis because it is what mature local-first tools converge on: IndexedDB owns anything you would want to index, list, or transact; OPFS owns anything you would have stored as a file on disk in a native app. Choosing per-data-shape rather than per-project removes most of the drama.
Questions people often ask
Is OPFS safe to rely on across browsers?
The core surface—directory handles, writable streams—is stable across evergreen browsers, and worker-only sync access handles are broadly supported in current versions. As with any storage API, feature-detect (if ('createSyncAccessHandle' in fileHandle)) and keep a fallback path such as IndexedDB blobs for older targets.
Where does SQLite fit?
The official SQLite project ships a WebAssembly build that uses OPFS as its persistence layer—effectively giving web apps a real embedded database file with SQL semantics. It sits on top of the storage choice rather than replacing it: SQLite-over-OPFS for relational needs, plain OPFS for opaque binaries, IndexedDB for queryable app state.
Can I stream writes without holding everything in memory?
That is precisely the writable-stream model: append chunks as they arrive, seek back to patch headers when formats require it, and let close() commit atomically. Combined with transferable buffers moving data from producers to a storage worker, end-to-end memory overhead stays proportional to chunk size, not total size.
Do I still need IndexedDB if I choose OPFS?
Usually yes, somewhere. OPFS has no indexes, no transactions, no cursors—any feature beyond “read these bytes at this offset” means rebuilding database machinery by hand. Most real applications run both, each over the shape it serves best.
The takeaway
IndexedDB and OPFS are not rivals but complements with opposite centers of gravity: queryable serialized records versus raw positional bytes. Let the data’s shape decide—index what you search, file what you process—and validate any performance folklore with a harness whose variables you controlled. Browsers now expose genuinely capable local persistence; using it well is mostly about respecting what each layer was shaped for.
Share this article
Link, preview card, or your favorite app
Instagram has no web share link — save the card, copy the caption, post them together.