OPFS vs IndexedDB for Large Binary Files
You need to keep multi-megabyte binary data on the device — a video buffer, an audio file, model weights, a generated archive, or a wasm SQLite database — and you have two real options: store it as a Blob in IndexedDB, or write it as a file in the Origin Private File System. Picking wrong shows up as sluggish reads, memory spikes, and a UI that stutters every time the file changes. This guide is the focused decision aid for that one choice; for the full API surface and locking rules, start from the parent guide Origin Private File System (OPFS). The short version: OPFS wins decisively for large, frequently-mutated, or randomly-accessed binary data, while IndexedDB still wins when those bytes must stay transactionally consistent with other records or when you query metadata about them.
Why IndexedDB struggles with large binary values
IndexedDB stores Blob and ArrayBuffer values natively, and for small-to-medium payloads it is perfectly adequate. The friction appears at scale, and it is structural rather than a tuning problem.
IndexedDB is a whole-value store. A record is the unit of read and write: to change one byte in a 200 MB blob you must read the entire value, mutate it in memory, and put the whole thing back. There is no seek, no partial write, no append. Every put and get of a structured value also pays the cost of the structured clone algorithm, which serializes and copies the value as it crosses into and out of the database — for large buffers that copy is the dominant cost. Reads and writes are mediated by transactions, which add ordering and durability guarantees you may not need for an opaque byte blob. And while you can run IndexedDB in a worker, you still cannot do byte-range random access; the granularity is the key.
OPFS inverts all of this. A FileSystemSyncAccessHandle opened in a Web Worker (covered in the parent guide Origin Private File System (OPFS)) gives blocking, offset-addressed read(buffer, { at }) and write(buffer, { at }) against a single file, with no structured-clone copy and no transaction overhead. You can overwrite bytes 1,000,000–1,000,512 of a gigabyte file without touching the rest. On the main thread, createWritable() lets you pipe a fetch stream straight to disk so the payload never fully materializes in memory. For the IndexedDB-side mechanics this guide compares against, see Storing Blobs & Files in IndexedDB.
Head-to-head comparison
| Dimension | OPFS | IndexedDB |
|---|---|---|
| Large-file throughput | Very high (sync handle in worker) | Moderate; structured-clone copy per value |
| Random access | Yes — read/write at a byte offset |
No — whole value per key |
| Streaming writes | Yes — pipe a ReadableStream to createWritable() |
No — must assemble the full Blob first |
| Partial update | Yes — overwrite a byte range | No — read-modify-write the entire value |
| API ergonomics | File-like; locking to manage | Familiar key-value with indexes |
| Transactions | None across files | ACID across object stores |
| Metadata querying | None built in | Indexes, ranges, cursors |
| Main thread vs worker | Async stream on main; sync handle worker-only | Works on both; async everywhere |
| Browser support | Baseline 2023; sync handle Safari 16.4+ | Universal, very old |
Decision guidance with runnable code
Use OPFS when the access pattern is file-shaped: streaming, appending, random reads, or a wasm database. Here is the OPFS write-and-readback path using the worker-side sync handle, which is the fast route.
// opfs-store.worker.ts — runs inside a Web Worker.
async function saveBytes(path: string, bytes: Uint8Array): Promise<void> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(path, { create: true });
const handle = await fileHandle.createSyncAccessHandle();
try {
handle.truncate(0);
handle.write(bytes, { at: 0 });
handle.flush();
} finally {
handle.close(); // release the exclusive lock
}
}
async function loadBytes(path: string): Promise<Uint8Array> {
const root = await navigator.storage.getDirectory();
const fileHandle = await root.getFileHandle(path);
const handle = await fileHandle.createSyncAccessHandle();
try {
const out = new Uint8Array(handle.getSize());
handle.read(out, { at: 0 });
return out;
} finally {
handle.close();
}
}
Use IndexedDB when the bytes are small, must be transactionally consistent with sibling records, or you need to query metadata. Here is the equivalent blob round-trip.
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('files', 1);
req.onupgradeneeded = () => req.result.createObjectStore('blobs');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveBlob(key: string, blob: Blob): Promise<void> {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').put(blob, key); // whole-value write
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function loadBlob(key: string): Promise<Blob | undefined> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction('blobs', 'readonly');
const req = tx.objectStore('blobs').get(key); // whole-value read
req.onsuccess = () => resolve(req.result as Blob | undefined);
req.onerror = () => reject(req.error);
});
}
Verification — measure it yourself
Do not take the comparison on faith; the gap is easy to confirm with performance.now(). Write the same payload through both stores and time it.
async function timeWrite(
label: string,
payload: Uint8Array,
write: (b: Uint8Array) => Promise<void>,
): Promise<void> {
const start = performance.now();
await write(payload);
const ms = performance.now() - start;
const mb = payload.byteLength / (1024 * 1024);
console.log(`${label}: ${ms.toFixed(1)} ms (${(mb / (ms / 1000)).toFixed(1)} MB/s)`);
}
const payload = crypto.getRandomValues(new Uint8Array(64 * 1024 * 1024)); // 64 MB
await timeWrite('IndexedDB', payload, (b) => saveBlob('bench', new Blob([b])));
// Run the OPFS write inside a worker and postMessage the timing back.
To inspect the result in DevTools: open Application → Storage. IndexedDB blobs appear under the IndexedDB tree with their object stores and keys. OPFS does not have a first-class panel in every browser yet, but navigator.storage.estimate() (callable from the console) reports total usage, and you can list OPFS entries by iterating entries() on the root directory. Compare the usage number before and after a write to confirm the bytes landed.
Edge cases and the hybrid fallback
OPFS is not always the answer. IndexedDB still wins when:
- The binary data must be written atomically alongside other records — for example, a document and its embedded image saved in one transaction. OPFS has no cross-file transactions, so a crash between two OPFS writes can leave them inconsistent.
- You need to query or index metadata — find all files larger than 5 MB, list by date, paginate. IndexedDB indexes and cursors do this natively; OPFS offers no querying.
- You must support the broadest possible browser matrix, including environments where the OPFS sync handle is unavailable.
The strongest pattern for media-heavy apps is a hybrid: keep queryable metadata as a normal IndexedDB record and store the heavy bytes in OPFS, linking them by path.
interface AssetMeta {
id: string;
opfsPath: string; // where the bytes live in OPFS
size: number;
mimeType: string;
createdAt: number;
}
// Write bytes to OPFS, then record queryable metadata in IndexedDB.
async function storeAsset(meta: AssetMeta, bytes: Uint8Array): Promise<void> {
await saveBytes(meta.opfsPath, bytes); // OPFS (worker-side)
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('blobs', 'readwrite');
tx.objectStore('blobs').put(meta, meta.id); // small, indexable record
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
This gives you IndexedDB’s querying over light metadata and OPFS’s throughput for the payload — at the cost of managing two stores and reconciling them (delete the OPFS file when you delete the metadata, and treat a missing file as a recoverable cache miss).
Frequently Asked Questions
Is OPFS always faster than IndexedDB for binary data?
For large files, frequent partial updates, streaming, and random access, yes — often by a wide margin, because the OPFS sync handle skips structured-clone copying and transaction overhead. For small blobs written occasionally the difference is negligible, and IndexedDB’s querying and transactional guarantees may matter more than raw speed.
Can I store a wasm SQLite database in IndexedDB instead of OPFS?
You can persist the database file as a blob in IndexedDB, but the database engine cannot do byte-level random reads and writes against it the way it can with an OPFS sync access handle. That is why high-performance wasm SQLite builds target OPFS in a worker; IndexedDB-backed variants exist but are slower for random access.
How do I keep OPFS files and IndexedDB metadata in sync?
Treat IndexedDB as the source of truth for which assets exist and OPFS as the byte store keyed by a path stored in the metadata record. Write the OPFS bytes first, then commit the metadata; on delete, remove the metadata then the file, and treat a missing OPFS file as a recoverable cache miss rather than corruption.
Related
- Origin Private File System (OPFS) — the parent guide with the full API, locking, and error model.
- Storing Blobs & Files in IndexedDB — the IndexedDB-side patterns this guide compares against.
- Storage Quotas & Eviction Policies — the shared origin quota both stores draw from.
- Browser Storage Fundamentals & Quotas — where these primitives fit in the wider storage picture.