Storing Large Blobs and Files in an Extension
Store images, PDFs, audio and other large binary data in an MV3 extension: why chrome.storage is the wrong place, IndexedDB with Blobs, the Origin Private File System, unlimitedStorage and eviction.
Table of Contents
The extension saves screenshots, caches article PDFs, records voice notes, or keeps offline copies of images. The first implementation base64-encodes each file and writes it to chrome.storage.local. It works for the first few files, then writes start failing with “QUOTA_BYTES quota exceeded”, reads of the whole store take seconds, and the popup freezes while it parses megabytes of JSON. chrome.storage is a small key-value store for JSON — the right home for settings and indexes, the wrong one for binary data. This guide moves large data to the stores built for it. It belongs to chrome.storage API and sync.
Why chrome.storage struggles with binary data
chrome.storage serialises every value to JSON. Binary data must be encoded — base64 adds a third to its size — and every get of a key returns the whole value, decoded and parsed, even if you need a thumbnail. chrome.storage.local has a 10 MB quota unless the extension requests unlimitedStorage, and large writes block other storage operations for the extension while they complete. The web platform offers two stores built for binary data that are fully available to extensions on their own origin: IndexedDB, which stores Blob objects natively and indexes metadata, and the Origin Private File System (OPFS), which gives a private file tree with streaming reads and writes. Both are available in the service worker and in every extension page, and both share one origin-scoped quota.
Step-by-step: binary data in the right place
1. Request durable, unlimited storage
1{ "permissions": ["storage", "unlimitedStorage"] }
Execution context: the manifest. unlimitedStorage lifts chrome.storage.local’s 10 MB cap and, importantly, exempts the extension origin’s IndexedDB, OPFS and Cache API data from normal quota limits and from eviction under storage pressure. It produces no install warning in Chrome. Without it, browsers may evict web-storage data for the extension when the disk fills, exactly as they would for a website.
2. Open an IndexedDB store for blobs and metadata
1// db.js — shared by worker and pages
2export function openDb() {
3 return new Promise((resolve, reject) => {
4 const req = indexedDB.open("readable", 2);
5 req.onupgradeneeded = () => {
6 const db = req.result;
7 if (!db.objectStoreNames.contains("files")) {
8 const s = db.createObjectStore("files", { keyPath: "id" });
9 s.createIndex("byCreated", "createdAt");
10 }
11 };
12 req.onsuccess = () => resolve(req.result);
13 req.onerror = () => reject(req.error);
14 });
15}
Execution context: the service worker or any extension page; they share the database because they share the extension origin. Content scripts run on the page’s origin and would open the page’s IndexedDB, not yours — they must go through messages. A small wrapper such as idb makes the promise plumbing lighter; the raw API is shown for clarity.
3. Store Blobs directly
1export async function saveFile({ id, blob, name, type }) {
2 const db = await openDb();
3 await new Promise((resolve, reject) => {
4 const tx = db.transaction("files", "readwrite");
5 tx.objectStore("files").put({ id, blob, name, type, size: blob.size, createdAt: Date.now() });
6 tx.oncomplete = resolve;
7 tx.onerror = () => reject(tx.error);
8 });
9}
10
11// e.g. saving a screenshot in the worker
12const dataUrl = await chrome.tabs.captureVisibleTab({ format: "png" });
13const blob = await (await fetch(dataUrl)).blob();
14await saveFile({ id: crypto.randomUUID(), blob, name: "shot.png", type: "image/png" });
Execution context: the service worker. IndexedDB stores the Blob as binary — no base64, no size inflation. Converting a data URL to a Blob through fetch is the simplest way in a worker. Keep metadata in the same record so listing files does not require a second store, but read lists through a cursor or index rather than getAll when files are large, so you do not load every blob into memory.
4. Display stored blobs in extension pages
1// popup.js
2const db = await openDb();
3const rec = await new Promise((res) => {
4 const r = db.transaction("files").objectStore("files").get(id);
5 r.onsuccess = () => res(r.result);
6});
7const url = URL.createObjectURL(rec.blob);
8img.src = url;
9img.addEventListener("load", () => URL.revokeObjectURL(url), { once: true });
Execution context: an extension page, where URL.createObjectURL exists (it does not in the service worker). Revoking the object URL after load frees the memory. To show a stored image inside a web page, send the bytes to the content script (as an ArrayBuffer in a message) and create the object URL there; a blob: URL from the extension origin cannot be loaded by a page.
5. Use OPFS for very large files
1export async function writeLargeFile(name, stream) {
2 const root = await navigator.storage.getDirectory();
3 const dir = await root.getDirectoryHandle("recordings", { create: true });
4 const file = await dir.getFileHandle(name, { create: true });
5 const writable = await file.createWritable();
6 await stream.pipeTo(writable); // streams, never all in memory
7}
8
9export async function readSlice(name, start, end) {
10 const root = await navigator.storage.getDirectory();
11 const file = await (await (await root.getDirectoryHandle("recordings")).getFileHandle(name)).getFile();
12 return file.slice(start, end); // a Blob view, read lazily
13}
Execution context: the service worker or an extension page. OPFS is a private file system per origin, invisible to the user’s file manager. Streaming writes avoid holding a 200 MB recording in memory; slice reads only the bytes needed. createWritable is supported in Chrome, Firefox and recent Safari; the synchronous access handle API is limited to dedicated workers and is not needed here.
6. Monitor usage and handle quota errors
1export async function storageReport() {
2 const { usage, quota } = await navigator.storage.estimate();
3 const persisted = await navigator.storage.persisted?.();
4 return { usageMB: Math.round(usage / 1e6), quotaMB: Math.round(quota / 1e6), persisted };
5}
Execution context: any extension context. estimate() covers IndexedDB, OPFS and Cache API for the extension origin — not chrome.storage, which has its own getBytesInUse. With unlimitedStorage, the quota figure is large and persisted is effectively true. Still catch QuotaExceededError on writes: the disk itself can fill. Show users how much space the extension uses and give them a way to delete old files.
7. Clean up what is no longer referenced
1export async function pruneOldFiles(maxAgeDays = 90) {
2 const db = await openDb();
3 const cutoff = Date.now() - maxAgeDays * 86_400_000;
4 const tx = db.transaction("files", "readwrite");
5 const idx = tx.objectStore("files").index("byCreated");
6 idx.openCursor(IDBKeyRange.upperBound(cutoff)).onsuccess = (e) => {
7 const cur = e.target.result;
8 if (cur) { cur.delete(); cur.continue(); }
9 };
10 await new Promise((r) => (tx.oncomplete = r));
11}
Execution context: the service worker, triggered by a weekly alarm or from the options page. Binary stores grow silently; a retention policy, applied through the createdAt index, keeps the extension from slowly consuming gigabytes. If the index in chrome.storage references files, update it in the same pass so the popup never lists something that no longer exists.
Common mistakes
- base64 blobs in
chrome.storage. Larger, slower, and quota-bound. - Opening IndexedDB from a content script. That is the page’s database. Message the worker.
createObjectURLin the worker. It is not available there; create URLs in pages.- No
unlimitedStorage. Data can be evicted under disk pressure. - No retention policy. Users discover the extension is using 4 GB only when their disk is full.
Cross-browser variation
- Chrome / Edge: IndexedDB, OPFS and Cache API available in the service worker;
unlimitedStorageexempts them from eviction. - Firefox: IndexedDB and OPFS available in the background and extension pages;
unlimitedStorageis supported. Extension IndexedDB data is removed when the extension is uninstalled. - Safari: IndexedDB works; OPFS support arrived in recent versions with
createWritablelater still — feature-detect. Safari’s storage quotas are tighter and it may prompt or evict more readily.
Verification
- Save several large files and confirm
chrome.storage.local.getBytesInUse()stays small whilenavigator.storage.estimate()grows. - Open DevTools on an extension page → Application → IndexedDB and confirm records hold
Blobvalues, not strings. - Reload the popup and confirm listing is instant and images load on demand.
- Run the prune job with a short max age and confirm old records disappear.
FAQ
Can the service worker read a file the user picked?
The file picker needs a page. Let the user choose in an extension page, then store the File (which is a Blob) in IndexedDB.
Is IndexedDB shared between Chrome profiles?
No. Each profile has its own copy of the extension’s storage.
Does chrome.storage.sync have a blob equivalent?
No. Sync binary data through your own backend if it needs to follow the user.
Related
- Choosing between chrome.storage and IndexedDB — the general decision.
- Watching storage usage with getBytesInUse — measuring the chrome.storage side.
- Keeping IndexedDB fast in an extension — performance at scale.
- chrome.storage API and sync — the parent topic.