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.
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
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.
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.
Cross-browser variation
- Chrome / Edge: the full surface is available, including
onDeterminingFilename,downloads.shelfandsetUiOptions. Data URLs are accepted bydownload();blob:URLs created in an offscreen document are too. - Firefox:
browser.downloadsimplements most of the API but has noonDeterminingFilename— filenames must be supplied atdownload()time. Firefox’s background context has a DOM in event pages, soURL.createObjectURLmay 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
urlrequires a host permission for that origin. Adata:URL requires none, which is another reason to prefer it for generated exports.
Verification
- 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.
- Confirm the file opens and round-trips through your import path.
- Trigger a download from a matching page and confirm
onDeterminingFilenamefiles it into your sub-directory. - 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.
Related
- Creating and closing offscreen documents — the DOM context for large blobs.
- Exporting and importing extension settings — the round trip this is half of.
- Justifying sensitive data permissions — explaining the downloads permission in review.
- Bookmarks, history and downloads APIs — the parent guide.