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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Where to keep each kind of datachrome.storage.local, IndexedDB, the Origin Private File System and the Cache API compared on binary support, size limits, partial reads and best use.StoreBinaryPractical sizePartial readsBest forchrome.storage.localbase64 only10 MB (or unlimit…NoSettings, small i…IndexedDBNative BlobLarge, quota-basedPer recordFiles + metadataOPFSNative filesLarge, quota-basedStreams, slicesVery large filesCache APIResponsesQuota-basedPer URLFetched resources
Keep metadata in chrome.storage; keep bytes in IndexedDB or OPFS.

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.

Saving and showing a screenshotThe worker captures the tab, converts the data URL to a Blob, stores it in IndexedDB with metadata, and writes a small index entry to chrome.storage; the popup lists entries from storage and loads the blob from IndexedDB for display.captureVisibleTabdata URLfetch → BlobbinaryIndexedDB putblob + metadatasmall index for fast listingstorage.local[{id, name, size}]Popup listno blobs loadedcreateObjectURLon demand
chrome.storage holds the list; IndexedDB holds the bytes.

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.

Size of a 2 MB screenshot by storage methodStored size of a 2 MB PNG as a base64 string in chrome.storage, as a Blob in IndexedDB and as a file in OPFS.base64 in chrome.storage2.67 MBBlob in IndexedDB2 MBFile in OPFS2 MB
Base64 costs a third more space before JSON overhead — binary stores keep the real size.

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.
  • createObjectURL in 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; unlimitedStorage exempts them from eviction.
  • Firefox: IndexedDB and OPFS available in the background and extension pages; unlimitedStorage is supported. Extension IndexedDB data is removed when the extension is uninstalled.
  • Safari: IndexedDB works; OPFS support arrived in recent versions with createWritable later still — feature-detect. Safari’s storage quotas are tighter and it may prompt or evict more readily.

Verification

  1. Save several large files and confirm chrome.storage.local.getBytesInUse() stays small while navigator.storage.estimate() grows.
  2. Open DevTools on an extension page → Application → IndexedDB and confirm records hold Blob values, not strings.
  3. Reload the popup and confirm listing is instant and images load on demand.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources