Storing Blobs & Files in IndexedDB

IndexedDB is the only mainstream browser store that can hold large binary payloads — user uploads, fetched media, generated documents — as first-class values without you serializing them by hand. Because the structured clone algorithm understands Blob, File, ArrayBuffer, and typed arrays such as Uint8Array, you can pass a Blob straight to objectStore.put() and read back a byte-identical Blob later, with no base64 round-trip. This guide covers the binary value model, a full upload-store-display walkthrough using URL.createObjectURL(), the transaction lifecycle traps that bite large writes, the quota and historic Safari failures you must defend against, and when a file genuinely belongs in OPFS instead. For the broader architecture this builds on, review IndexedDB Architecture & Advanced Patterns.

Blob lifecycle through IndexedDB and back to the DOM A diagram showing a File or fetched Response stored as a Blob in IndexedDB via structured clone, then read back and turned into an object URL for an img element, with the URL revoked afterward. File / Response input or fetch() IndexedDB put(Blob) · clone img.src object URL get(key) returns Blob revokeObjectURL() when done unreleased URLs leak memory for the page lifetime

Why you can store binary directly

IndexedDB serializes values with the structured clone algorithm, not JSON.stringify. That algorithm has native support for binary types, so Blob, File (which is a Blob subclass), ArrayBuffer, and every typed-array view are stored as opaque byte sequences and reconstructed faithfully on read. You never call FileReader, you never base64-encode, and you never lose the MIME type — a stored Blob retains its type property. This is the single biggest reason to choose IndexedDB over localStorage for any non-trivial binary asset: localStorage only holds strings, forcing a base64 detour that inflates size and burns CPU. For the full picture of what structured clone can and cannot copy, see Data Serialization & Deserialization.

Binary value types compared

You will choose between three representations depending on where the bytes come from and how you consume them. Prefer Blob/File when you display or download the data, and ArrayBuffer/Uint8Array when you compute over the bytes (hashing, parsing, slicing).

Type What it is Best for Storage cost Display path
Blob / File Immutable byte container with a MIME type Uploads, media, downloads Raw bytes (1×) URL.createObjectURL()
ArrayBuffer Fixed-length raw buffer, no view Crypto, binary parsing Raw bytes (1×) Wrap in Blob first
Uint8Array Typed view over a buffer Byte manipulation, chunking Raw bytes (1×) Wrap in Blob first
base64 string Text encoding of bytes Inlining tiny icons only ~1.33× + CPU data: URL

The takeaway is that the three binary forms all cost one byte per byte, while a base64 string costs roughly a third more plus encode/decode time. Reach for base64 only when a value must live inside a text field — and even then, the related guide on Storing Images as Blobs vs Base64 in IndexedDB shows why it is usually the wrong default.

API spec: putting and getting a Blob

The API surface is the standard object-store interface; the binary value just rides along. Open the database, start a readwrite transaction, and put() the Blob like any other value. Reading is a get() on a readonly transaction that hands you back a Blob.

// Promise wrappers over the event-based IndexedDB API.
function openDb(name: string, version = 1): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(name, version);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains('files')) {
        db.createObjectStore('files', { keyPath: 'id' });
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

interface FileRecord {
  id: string;
  name: string;
  type: string;
  size: number;
  blob: Blob; // stored directly — no serialization
}

function putBlob(db: IDBDatabase, record: FileRecord): Promise<void> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('files', 'readwrite');
    tx.objectStore('files').put(record);
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error);
  });
}

function getBlob(db: IDBDatabase, id: string): Promise<FileRecord | undefined> {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('files', 'readonly');
    const req = tx.objectStore('files').get(id);
    req.onsuccess = () => resolve(req.result as FileRecord | undefined);
    req.onerror = () => reject(req.error);
  });
}

Resolving the promise on the transaction’s oncomplete rather than the request’s onsuccess is deliberate: a request can succeed while the transaction later aborts (for example, on quota failure), so the durable signal is oncomplete. The same reasoning underpins all the patterns in IndexedDB Transaction Management.

Walkthrough: store an uploaded file, then display it

This is the canonical flow — take a File from an <input type="file">, persist it, then later read it back and show it. The key DOM detail is that URL.createObjectURL() mints a short string URL pointing at the in-memory Blob, which you assign to img.src. That URL must be released with URL.revokeObjectURL() or it pins the Blob in memory until the document unloads.

async function storeUpload(db: IDBDatabase, file: File): Promise<string> {
  const id = crypto.randomUUID();
  await putBlob(db, {
    id,
    name: file.name,
    type: file.type,
    size: file.size,
    blob: file, // File IS a Blob; stored as-is via structured clone
  });
  return id;
}

async function displayStored(db: IDBDatabase, id: string, img: HTMLImageElement): Promise<void> {
  const record = await getBlob(db, id);
  if (!record) throw new Error(`No file for id ${id}`);

  const url = URL.createObjectURL(record.blob);
  img.src = url;

  // Release the object URL once the bytes have been decoded into the image,
  // otherwise the Blob stays resident in memory for the page's lifetime.
  img.onload = () => URL.revokeObjectURL(url);
  img.onerror = () => URL.revokeObjectURL(url);
}

// Wiring it to an input element:
function wireUpload(db: IDBDatabase, input: HTMLInputElement, img: HTMLImageElement): void {
  input.addEventListener('change', async () => {
    const file = input.files?.[0];
    if (!file) return;
    const id = await storeUpload(db, file);
    await displayStored(db, id, img);
  });
}

Storing a fetched Response body

Network responses are equally easy to persist: call response.blob() to drain the body into a Blob, then store it. This is the foundation of an offline media cache that survives reloads — though for cacheable HTTP responses keyed by URL, weigh it against the dedicated Cache API for Static Assets.

async function cacheRemoteImage(db: IDBDatabase, url: string): Promise<string> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
  const blob = await res.blob(); // body becomes a Blob, MIME type preserved
  const id = crypto.randomUUID();
  await putBlob(db, { id, name: url, type: blob.type, size: blob.size, blob });
  return id;
}

Concurrency & lifecycle: don’t let the transaction auto-commit

The most common failure when writing a large Blob is not the write itself but the surrounding transaction closing underneath you. An IndexedDB transaction auto-commits as soon as its request queue drains and the event loop yields with no pending requests. If you await a network fetch or a response.blob() call between opening the transaction and calling put(), the transaction has already committed by the time you return, and the put() throws TransactionInactiveError.

The rule is: do all async work before opening the transaction, then issue the put() synchronously inside it.

// CORRECT: resolve the Blob first, then open a transaction and write synchronously.
async function safeStore(db: IDBDatabase, url: string): Promise<void> {
  const blob = await (await fetch(url)).blob(); // async work happens first

  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction('files', 'readwrite'); // opened only now
    tx.objectStore('files').put({
      id: crypto.randomUUID(),
      name: url,
      type: blob.type,
      size: blob.size,
      blob,
    });
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

A single large Blob write stays inside one transaction with no intervening awaits, so it commits atomically. The detailed mechanics — and the platform timing quirk where even a well-formed transaction can commit early — are dissected in The IndexedDB Transaction Auto-Commit Timing Bug.

Error handling: quota and the historic Safari Blob bug

Two failure modes dominate binary storage. The first is quota: a large write that exceeds the origin’s allotment aborts the transaction with a QuotaExceededError on tx.error. You must catch it on the transaction, free space or prompt the user, and retry — never assume a put() of multi-megabyte data will succeed.

async function storeWithQuotaGuard(db: IDBDatabase, record: FileRecord): Promise<boolean> {
  try {
    await putBlob(db, record);
    return true;
  } catch (err) {
    if (err instanceof DOMException && err.name === 'QuotaExceededError') {
      // Inspect remaining budget and decide whether to evict or warn.
      const { quota = 0, usage = 0 } = await navigator.storage.estimate();
      console.warn(`Quota exceeded: ${usage}/${quota} bytes used`);
      return false;
    }
    throw err;
  }
}

The second is historical but still relevant to compatibility tables: WebKit shipped a long-standing defect where storing a Blob in IndexedDB was unreliable. Through Safari 9 and into the Safari 10.x line, Blobs written to IndexedDB could come back as empty or corrupt, and many teams worked around it by storing an ArrayBuffer and reconstructing the Blob on read. The bug was resolved in Safari 11 (2017); for any target below that, the ArrayBuffer fallback below is the safe path. For broader quota strategy and recovery, see Storage Quotas & Eviction Policies.

// Defensive fallback for ancient WebKit: store bytes as ArrayBuffer,
// rebuild a typed Blob on read. Unnecessary on Safari 11+.
async function putAsBuffer(db: IDBDatabase, id: string, blob: Blob): Promise<void> {
  const buffer = await blob.arrayBuffer();
  await putBlob(db, {
    id, name: id, type: blob.type, size: blob.size,
    blob: new Blob([buffer], { type: blob.type }),
  } as FileRecord);
}

Browser compatibility

Direct Blob/File storage is universal in current browsers; the only meaningful caveats are old WebKit and the eviction behavior of iOS Safari.

Browser Blob in IndexedDB Notes
Chrome / Edge Yes (all current) Stable; large Blobs spill to disk-backed storage
Firefox Yes (all current) Stable; honors navigator.storage.persist()
Safari (desktop) Yes, Safari 11+ Broken on Safari 9–10.x — empty/corrupt Blobs
iOS Safari 16 Yes 7-day eviction of script-writable storage when idle
iOS Safari 17 Yes Same eviction cap; installed PWAs get a separate budget

iOS Safari 16 and 17 store Blobs correctly but apply Intelligent Tracking Prevention’s 7-day inactivity wipe to IndexedDB, so a cached media library can vanish overnight. Treat the local copy as a cache that may be re-fetched, and budget for the iOS Safari 7-Day Storage Eviction Workaround where persistence matters.

Performance & scale

Frequently Asked Questions

Can I really store a File object in IndexedDB without converting it?

Yes. File extends Blob, and IndexedDB’s structured clone algorithm copies both binary types natively. Pass the File straight to objectStore.put(); on read you get back a Blob with the original bytes and MIME type intact, with no FileReader or base64 step.

Why does my Blob image leak memory after I display it?

URL.createObjectURL() keeps the Blob alive until you call URL.revokeObjectURL() on that exact URL. Revoke it in the image’s onload/onerror handler, or on component unmount, so the bytes can be garbage-collected. Forgetting this pins every displayed Blob in memory for the page’s lifetime.

My Blobs come back empty in older Safari — is that my code?

Probably not. WebKit had a defect through Safari 9 and 10.x where Blobs stored in IndexedDB could return empty or corrupt; it was fixed in Safari 11. If you must support those versions, store the data as an ArrayBuffer and reconstruct the Blob with new Blob([buffer], { type }) on read.

When should I move binary files out of IndexedDB and into OPFS?

Move when files are very large, need streaming or partial reads, or require random access — the Origin Private File System gives synchronous access handles and is built for that workload. See OPFS vs IndexedDB for Large Binary Files for the decision criteria.

Related