Storing Images as Blobs vs Base64 in IndexedDB
You are caching images offline in IndexedDB and have to pick a representation: encode each image as a base64 data: URL string, or store the raw Blob. The base64 path is tempting because the value is a plain string you can drop straight into img.src. It is also the slower, larger, more memory-hungry choice in almost every case. This guide explains why, then gives a complete runnable implementation of the Blob approach and shows the base64 version for contrast. It is a focused companion to Storing Blobs & Files in IndexedDB, which covers the full binary-value API.
Root-cause analysis: why base64 is the worse default
Base64 is a text encoding that maps every 3 bytes of binary into 4 ASCII characters. That ratio is the whole problem. A 900 KB JPEG becomes a roughly 1.2 MB string — about 33% larger — and you pay that inflation everywhere: more bytes against your storage quota, more bytes serialized in and out of IndexedDB, and a larger string held in JavaScript memory. On top of the size penalty there is CPU cost. Encoding to base64 (via FileReader.readAsDataURL or btoa) walks the entire byte array, and the browser then has to decode that base64 again when it parses the data: URL to render the image. Every display repeats the decode.
The structured clone algorithm that backs IndexedDB stores Blob objects as raw bytes with no encoding step at all, so the Blob path skips both the size inflation and the encode/decode CPU. Rendering a stored Blob uses URL.createObjectURL(), which hands the image element a pointer to the in-memory bytes — there is no string to parse and effectively no per-render cost. The browser reads the original bytes directly. That is why the consistent recommendation across this site is to store images as Blobs and reserve base64 for the narrow case of inlining a tiny icon into a JSON record.
Step-by-step: the Blob approach (recommended)
The flow is: fetch the image, drain it to a Blob with response.blob(), put() the Blob in IndexedDB, then on display read it back, mint an object URL, assign it to img.src, and revoke the URL when done. The following is complete and runnable.
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open('image-cache', 1);
req.onupgradeneeded = () => {
req.result.createObjectStore('images', { keyPath: 'url' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
interface ImageRecord {
url: string;
blob: Blob;
}
// 1. Fetch the image and store its raw bytes as a Blob.
async function cacheImage(db: IDBDatabase, url: string): Promise<void> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const blob = await res.blob(); // raw bytes, MIME type preserved
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('images', 'readwrite');
tx.objectStore('images').put({ url, blob } satisfies ImageRecord);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// 2. Read the Blob back and render it through an object URL.
function readImage(db: IDBDatabase, url: string): Promise<Blob | undefined> {
return new Promise((resolve, reject) => {
const tx = db.transaction('images', 'readonly');
const req = tx.objectStore('images').get(url);
req.onsuccess = () => resolve((req.result as ImageRecord | undefined)?.blob);
req.onerror = () => reject(req.error);
});
}
async function showImage(db: IDBDatabase, url: string, img: HTMLImageElement): Promise<void> {
let blob = await readImage(db, url);
if (!blob) {
await cacheImage(db, url); // miss: fetch and store, then re-read
blob = await readImage(db, url);
}
if (!blob) throw new Error('Image unavailable');
const objectUrl = URL.createObjectURL(blob);
img.src = objectUrl;
// 3. Revoke once decoded so the Blob can be freed.
img.onload = () => URL.revokeObjectURL(objectUrl);
img.onerror = () => URL.revokeObjectURL(objectUrl);
}
// Revoke any in-flight URLs when the page goes away as a safety net.
const liveUrls = new Set<string>();
window.addEventListener('pagehide', () => {
for (const u of liveUrls) URL.revokeObjectURL(u);
liveUrls.clear();
});
The base64 approach, for contrast
For comparison, here is the same cache built on base64. Note the extra FileReader encode step on write and that the value stored is a string roughly a third larger than the bytes it represents.
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string); // "data:image/...;base64,..."
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob); // encodes every byte to base64 text
});
}
async function cacheImageBase64(db: IDBDatabase, url: string): Promise<void> {
const res = await fetch(url);
const dataUrl = await blobToDataUrl(await res.blob()); // ~33% larger string
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('images', 'readwrite');
tx.objectStore('images').put({ url, dataUrl });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Display: assign the data URL directly — but the browser re-decodes the
// base64 every time it parses the string to render the image.
async function showImageBase64(db: IDBDatabase, url: string, img: HTMLImageElement): Promise<void> {
const record = await new Promise<{ dataUrl: string } | undefined>((resolve, reject) => {
const tx = db.transaction('images', 'readonly');
const req = tx.objectStore('images').get(url);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (record) img.src = record.dataUrl;
}
The base64 version is shorter to wire up because the string drops straight into img.src, which is its only real advantage. It pays for that convenience with larger storage, an encode pass on write, and a decode pass on every render — and it never needs an object URL revoke because there is no Blob to free, which is the one bookkeeping task it removes.
Verification
Confirm the difference empirically:
- Compare stored sizes. Open DevTools, go to Application > IndexedDB, expand your
image-cachestore, and inspect a record. The Blob record shows the original byte size; the base64 record’sdataUrlstring is visibly longer. Cross-check the origin total under Application > Storage — the base64 build reports roughly 33% higher usage for the same images. - Measure decode time. Wrap the assignment to
img.srcand theonloadinperformance.now()timestamps for both versions and log the delta. The base64 render consistently spends more time beforeonloadfires because the engine decodes the data URL each time.
async function timeRender(show: () => Promise<void>, img: HTMLImageElement): Promise<number> {
const start = performance.now();
await show();
await new Promise<void>((r) => { img.onload = () => r(); });
return performance.now() - start; // base64 path is reliably larger
}
Edge cases & fallback
There are narrow cases where base64 is acceptable. The clearest is tiny icons — a 1 KB sprite or placeholder — that you want to inline inside a single JSON record alongside other fields, so the whole record is one structured value with no separate Blob to manage. The 33% overhead on a kilobyte is negligible, and you avoid the object-URL lifecycle entirely. SVG is a related case: it is already text, so storing the markup as a string (and rendering via a data:image/svg+xml URL or by injecting it into the DOM) sidesteps base64 binary encoding altogether — there is no raw-byte advantage to capture.
The real hazard in the Blob path is object URL lifetime. Each URL.createObjectURL() pins its Blob in memory until you revoke it; forget the revoke in a long-scrolling gallery and memory climbs until the page unloads. Always revoke in onload/onerror, on component unmount, and as a pagehide safety net as shown above. If you ever cannot guarantee a revoke point — for example a fire-and-forget render in a context with no lifecycle hook — base64 is the pragmatic fallback precisely because it has no resource to leak. For everything else, the Blob path in Storing Blobs & Files in IndexedDB wins on size, CPU, and memory.
Frequently Asked Questions
How much bigger is base64 than the raw image?
About 33% larger. Base64 encodes every 3 bytes as 4 ASCII characters, so a 900 KB image becomes roughly a 1.2 MB string. That inflation hits your storage quota, the bytes serialized into IndexedDB, and the string held in memory — all avoidable by storing the raw Blob.
Is there any case where base64 is the right choice?
Yes, for tiny assets you want to inline into a single JSON record — a small icon or placeholder where the ~33% overhead is trivial and you would rather skip the object-URL lifecycle. SVG is similar: it is already text, so store the markup as a string. For anything larger, store a Blob.
Do I have to revoke object URLs?
Yes. URL.createObjectURL() keeps its Blob alive until you call URL.revokeObjectURL(). Revoke in the image’s onload/onerror, on unmount, and on pagehide. Skipping it leaks memory for the page’s lifetime, which is the one bookkeeping cost the base64 approach avoids.
Related
- Storing Blobs & Files in IndexedDB — the full binary-value API this decision sits inside.
- Data Serialization & Deserialization — why structured clone stores Blobs without any encoding step.
- Storage Quotas & Eviction Policies — the quota the 33% base64 overhead eats into.
- IndexedDB Architecture & Advanced Patterns — the parent guide to the IndexedDB model.