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.

Decision tree for storing large binary data A decision tree: if the file needs random access or streaming, choose OPFS; if it must be transactional with other records or broadly supported, choose IndexedDB; otherwise consider a hybrid. Random access or streaming needed? Choose OPFS sync handle in a worker Transactional with other records? Choose IndexedDB Blob value per key Hybrid: metadata in IDB, bytes in OPFS yes no yes no

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