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.
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
- Never base64-encode for storage. Base64 inflates payloads by about 33% and costs CPU on both encode and decode, then often costs again to display. Storing raw
Blobbytes is smaller and faster end to end — the side-by-side measurement is in Storing Images as Blobs vs Base64 in IndexedDB. - Chunk very large files. A multi-gigabyte upload held entirely in one
Blobrisks a single quota abort that loses everything. Slice withblob.slice(start, end)into fixed-size chunks stored under sequential keys, so a failed chunk retries independently and progress survives a crash. - Read lazily. Store the
Blobonce and only callcreateObjectURL()when the asset is actually rendered; revoke as soon as it scrolls out of view to keep memory flat in long lists. - Know when to leave. IndexedDB is excellent up to tens or low hundreds of megabytes per asset, but for very large files with streaming reads, partial writes, or random access, the Origin Private File System is purpose-built. Compare the two in Origin Private File System (OPFS) and the focused OPFS vs IndexedDB for Large Binary Files.
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
- Storing Images as Blobs vs Base64 in IndexedDB — the size and CPU comparison behind the Blob-first recommendation.
- IndexedDB Transaction Management — keep large binary writes inside one durable transaction.
- Origin Private File System (OPFS) — where very large files belong instead of IndexedDB.
- Data Serialization & Deserialization — how structured clone copies binary types without base64.
- IndexedDB Architecture & Advanced Patterns — the parent guide to the IndexedDB model.