Managing Downloads from an Extension

Start, name, track and clean up downloads with chrome.downloads in MV3 — blob URLs from a worker, onDeterminingFilename, progress events and the Safari gaps.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

Exporting data from an extension looks like a one-liner until you try it in a service worker: there is no document, so URL.createObjectURL on a Blob is unavailable, and an anchor click is not an option either. chrome.downloads.download is the API that works from a worker — and it brings its own questions about filenames, progress and cleanup. This guide is part of bookmarks, history and downloads APIs.

Getting bytes out of a worker

How to produce a downloadable URLA decision tree covering data URLs for small text exports, offscreen documents for blob URLs, and a remote URL when the file already exists on a server.Where do the bytes come from?Generated, smalldata: URLunder ~2 MBEncode in the workerno DOM neededGenerated, largeOffscreen documentcreateObjectURL thereRevoke after download startsonChanged stateAlready on a serverPass the https URLdownloads.download({url})Host permission requiredfor the origin
Data URLs cover most exports; reach for an offscreen document only when the payload is genuinely large.

Step-by-step

1. Declare the permission

1{
2  "permissions": ["downloads"],
3  "optional_permissions": ["downloads.shelf"]   // hiding the download bar, Chrome only
4}

Execution context: parsed at install. "downloads" alone lets you start and observe downloads. "downloads.open" is a separate, more sensitive permission needed to open a downloaded file, and reviewers will ask why you need it.

2. Export a small file with a data URL

 1async function exportSettings() {
 2  const { settings } = await chrome.storage.local.get("settings");
 3  const json = JSON.stringify(settings, null, 2);
 4  const url = "data:application/json;base64," + btoa(unescape(encodeURIComponent(json)));
 5
 6  const id = await chrome.downloads.download({
 7    url,
 8    filename: `reader-settings-${new Date().toISOString().slice(0, 10)}.json`,
 9    saveAs: true,
10  });
11  return id;
12}

Execution context: the service worker. btoa needs Latin-1, hence the encodeURIComponent/unescape pair for UTF-8 safety. saveAs: true shows the system dialog, which is the honest default for an export the user asked for.

3. Use an offscreen document for large blobs

 1// worker: ensure the offscreen document exists, then ask it for a blob URL
 2async function blobUrlFor(text, mime) {
 3  await ensureOffscreen();
 4  return chrome.runtime.sendMessage({ type: "offscreen:blobUrl", text, mime });
 5}
 6
 7// offscreen.js
 8chrome.runtime.onMessage.addListener((msg, _s, respond) => {
 9  if (msg.type !== "offscreen:blobUrl") return false;
10  const url = URL.createObjectURL(new Blob([msg.text], { type: msg.mime }));
11  respond(url);
12  return true;
13});

Execution context: the first block runs in the service worker; the second in an offscreen document, which is a real DOM context with URL.createObjectURL. The lifecycle of that document is covered in creating and closing offscreen documents.

4. Track the download to completion

 1function waitForDownload(id) {
 2  return new Promise((resolve) => {
 3    function onChanged(delta) {
 4      if (delta.id !== id || !delta.state) return;
 5      if (delta.state.current === "complete" || delta.state.current === "interrupted") {
 6        chrome.downloads.onChanged.removeListener(onChanged);
 7        resolve(delta.state.current);
 8      }
 9    }
10    chrome.downloads.onChanged.addListener(onChanged);
11  });
12}

Execution context: the service worker. The listener is added inside a promise the caller awaits, which keeps the worker alive for the duration — acceptable for an export, but not for a multi-gigabyte download where the worker’s five-minute ceiling will arrive first. For those, register onChanged at the top level and reconcile from storage.

5. Control the filename from an event

onDeterminingFilename lets you rename a download started by the page rather than by you — useful for organising files into folders.

1chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
2  if (!item.url.includes("reports.example.com")) return false;
3  suggest({ filename: `reports/${item.filename}`, conflictAction: "uniquify" });
4  return true;                 // claim this download
5});

Execution context: the service worker, registered at the top level. Only one extension may claim a given download; returning false politely declines and lets another extension or the browser decide. Sub-directories are relative to the downloads folder and cannot escape it.

6. Clean up what you created

1async function finishExport(id, objectUrl) {
2  const state = await waitForDownload(id);
3  if (objectUrl) await chrome.runtime.sendMessage({ type: "offscreen:revoke", url: objectUrl });
4  if (state === "complete") await chrome.downloads.erase({ id });   // remove from the list, not from disk
5}

Execution context: the service worker. erase removes the record from the browser’s download list; it does not delete the file. removeFile deletes the file and is a far more invasive call — do not use it without an explicit user action.

An export from click to cleanupThe popup asks the worker to export; the worker builds a data URL, starts the download, waits for completion and then erases the list entry.PopupService workerdownloads APIDiskexport clickserialise + encodedownloads.download({saveAs})user picks a locationonChanged state: completeerase({id})list entry only
The worker stays alive only while the download promise is pending — a large file needs a top-level listener instead.

Progress, resumption and the long download

Everything above assumes an export that finishes in a second or two. A download of a real file — a backup archive, a video the user asked to save — can outlive several worker lifetimes, and the promise-based tracking in step 4 will not survive that. For anything that might run for minutes, the listener has to be registered at the top level and the state has to live in storage.

 1// Top level of the service worker — survives eviction.
 2chrome.downloads.onChanged.addListener(async (delta) => {
 3  const { watched = {} } = await chrome.storage.session.get("watched");
 4  const entry = watched[delta.id];
 5  if (!entry) return;
 6
 7  if (delta.state?.current === "complete") {
 8    delete watched[delta.id];
 9    await chrome.storage.session.set({ watched });
10    await notifyDone(entry);
11  } else if (delta.state?.current === "interrupted") {
12    entry.error = delta.error?.current;
13    await chrome.storage.session.set({ watched });
14  }
15});

Execution context: the service worker, registered synchronously at the top level so a completion event that wakes a cold worker is delivered. chrome.storage.session is the right store: a download id means nothing after a browser restart, and neither does the watch entry.

Progress is polled rather than pushed. onChanged fires on state transitions, not on every byte, so a progress bar needs chrome.downloads.search on a timer — and in a worker, “on a timer” means an alarm with its one-minute floor, which is too coarse for a progress bar. The practical answer is to poll from the surface that is actually showing the bar:

1// popup.js — poll only while the popup is open
2const timer = setInterval(async () => {
3  const [item] = await chrome.downloads.search({ id });
4  bar.value = item.totalBytes ? item.bytesReceived / item.totalBytes : 0;
5  if (item.state !== "in_progress") clearInterval(timer);
6}, 500);

Execution context: the popup document, which owns its own event loop and is destroyed when the popup closes — taking the interval with it, which is exactly the desired behaviour. The same code in a worker would be the anti-pattern described in alarms vs setTimeout in service workers.

Interrupted downloads can sometimes be resumed with chrome.downloads.resume(id), but only when item.canResume is true — a network drop usually is resumable, a cancelled download is not.

A long download across three worker lifetimesA download running for four minutes while the service worker is evicted twice and woken by onChanged events at each state transition.download starts+4 minWor…star…Evicteddownload continuesWok…inte…Evictedresumed, still runningWoke…notif…network drop → canResumefile on disk
The worker is absent for most of the download — which is why the watch list lives in storage, not in memory.

Cross-browser variation

  • Chrome / Edge: the full surface is available, including onDeterminingFilename, downloads.shelf and setUiOptions. Data URLs are accepted by download(); blob: URLs created in an offscreen document are too.
  • Firefox: browser.downloads implements most of the API but has no onDeterminingFilename — filenames must be supplied at download() time. Firefox’s background context has a DOM in event pages, so URL.createObjectURL may be available directly.
  • Safari: partial. Downloads can be started, but filename determination and much of the tracking surface is missing. Test the export path specifically rather than assuming parity.
  • All three: a download started with a remote url requires a host permission for that origin. A data: URL requires none, which is another reason to prefer it for generated exports.

Verification

  1. Start an export and inspect the record:
1const [item] = await chrome.downloads.search({ id });
2({ state: item.state, bytes: item.totalBytes, file: item.filename });
3// { state: "complete", bytes: 18422, file: "/home/u/Downloads/reader-settings-2026-09-18.json" }

Execution context: the service worker console. A state of interrupted carries an error field naming the cause — USER_CANCELED is the common and entirely normal one.

  1. Confirm the file opens and round-trips through your import path.
  2. Trigger a download from a matching page and confirm onDeterminingFilename files it into your sub-directory.
  3. Confirm the download list entry is erased afterwards and that the file itself is still on disk.

FAQ

Why can’t I use URL.createObjectURL in the service worker?

Because it is a DOM API and the worker has no DOM. URL exists there, but createObjectURL does not. Use a data: URL or an offscreen document.

Is there a size limit on data: URLs?

Not a specified one, but encoding to base64 grows the payload by a third and the whole string is held in memory as a JavaScript value. Past a couple of megabytes, the offscreen route is both faster and kinder to the worker.

Can I download to a specific folder?

Only to sub-directories of the user’s downloads folder, and only with a relative path. Absolute paths and .. segments are rejected.

Other Core APIs & Cross-Browser Data Management Resources