Tracking Download Progress and Failures
Monitor downloads from an MV3 extension: why onChanged has no byte counts, polling search() for progress, interrupt reasons, resuming with canResume, per-download state across eviction and progress in the badge.
Table of Contents
The extension starts a large download — an export, a video, a batch of files — and wants to show progress and tell the user when it fails. chrome.downloads.onChanged looks like the obvious progress event, but it never reports bytes received: it fires for state transitions, filename changes and errors, not for progress. Progress has to be polled. And a failed download comes back with an interrupt reason like NETWORK_FAILED or SERVER_FORBIDDEN that needs translating into something the user can act on. This guide builds a download tracker that handles both. It belongs to browser data APIs for bookmarks, history and downloads.
What downloads events report
A download has a lifecycle: in_progress, then complete or interrupted, with paused as a flag on in-progress downloads. onCreated fires with the full DownloadItem when a download begins. onChanged fires with a delta — only the fields that changed, each as { previous, current } — for state, error, filename, mime, totalBytes, paused, canResume, danger and a few others. bytesReceived is deliberately absent from deltas because it changes continuously. To show progress, call chrome.downloads.search({ id }) periodically while a download is in progress and read bytesReceived and totalBytes from the result. onErased fires when an entry is removed from the downloads list.
Step-by-step: progress and failure handling
1. Start the download and remember it
1// sw.js
2export async function startExport(url) {
3 const id = await chrome.downloads.download({ url, filename: "readable/export.zip", conflictAction: "uniquify" });
4 const { tracked = {} } = await chrome.storage.session.get("tracked");
5 tracked[id] = { startedAt: Date.now(), label: "Export" };
6 await chrome.storage.session.set({ tracked });
7 ensurePolling();
8 return id;
9}
Execution context: the service worker. filename is relative to the user’s download directory; subfolders are created as needed. Storing tracked ids in chrome.storage.session lets a restarted worker continue tracking the same downloads. Only track downloads your extension started — onCreated also fires for every download the user makes, which you should not record without a reason.
2. Poll for progress only while something is in flight
1let pollTimer = null;
2
3function ensurePolling() {
4 if (pollTimer) return;
5 pollTimer = setInterval(pollOnce, 1000);
6}
7
8async function pollOnce() {
9 const { tracked = {} } = await chrome.storage.session.get("tracked");
10 const ids = Object.keys(tracked).map(Number);
11 if (ids.length === 0) { clearInterval(pollTimer); pollTimer = null; return; }
12 const items = (await Promise.all(ids.map((id) => chrome.downloads.search({ id })))).flat();
13 for (const d of items) {
14 if (d.state !== "in_progress") continue;
15 const pct = d.totalBytes > 0 ? Math.floor((d.bytesReceived / d.totalBytes) * 100) : null;
16 await chrome.storage.session.set({ [`dl:${d.id}`]: { pct, received: d.bytesReceived, total: d.totalBytes } });
17 }
18}
Execution context: the service worker. A one-second interval while a download is active is cheap; stopping as soon as nothing is in flight lets the worker sleep. Each search call and storage write is activity that keeps the worker alive during the download, which is what you want. totalBytes is 0 when the server sends no Content-Length; show an indeterminate indicator in that case.
3. Handle completion and interruption in onChanged
1chrome.downloads.onChanged.addListener(async (delta) => {
2 const { tracked = {} } = await chrome.storage.session.get("tracked");
3 if (!tracked[delta.id]) return;
4 if (delta.state?.current === "complete") return finish(delta.id, { ok: true });
5 if (delta.state?.current === "interrupted" || delta.error) {
6 const [item] = await chrome.downloads.search({ id: delta.id });
7 return finish(delta.id, { ok: false, reason: item.error, canResume: item.canResume });
8 }
9});
Execution context: the service worker, with the listener at the top level so completion wakes an evicted worker. Re-reading the item with search on interruption gives the final error and canResume values in one place. finish should remove the id from tracked, clear the dl: progress key, and notify the user.
4. Translate interrupt reasons into actions
1const REASONS = {
2 NETWORK_FAILED: { msg: "Network connection was lost.", action: "resume" },
3 NETWORK_TIMEOUT: { msg: "The download timed out.", action: "resume" },
4 SERVER_FORBIDDEN: { msg: "The server refused the download — try signing in again.", action: "restart" },
5 SERVER_BAD_CONTENT: { msg: "The file is no longer available.", action: "none" },
6 FILE_NO_SPACE: { msg: "Your disk is full.", action: "none" },
7 FILE_ACCESS_DENIED: { msg: "The download folder is not writable.", action: "none" },
8 USER_CANCELED: { msg: "Download cancelled.", action: "none" },
9};
10
11export function describe(reason) {
12 return REASONS[reason] ?? { msg: `Download failed (${reason}).`, action: "restart" };
13}
Execution context: a shared module. The interrupt reason is the most useful diagnostic you get; mapping it to a sentence and a next step turns a generic “failed” into something the user can fix. Do not show a retry button for USER_CANCELED — the user did that on purpose.
5. Resume where possible
1export async function retry(id) {
2 const [item] = await chrome.downloads.search({ id });
3 if (item?.canResume) return chrome.downloads.resume(id);
4 return startExport(item.url); // start over
5}
Execution context: the service worker, triggered by a button in a notification or the popup. resume continues from the bytes already received if the server supports range requests; otherwise it fails and you start a new download. A new download gets a new id — update tracking accordingly.
6. Show progress where the user is looking
1async function showBadgeProgress() {
2 const all = await chrome.storage.session.get(null);
3 const active = Object.entries(all).filter(([k]) => k.startsWith("dl:")).map(([, v]) => v.pct).filter((p) => p != null);
4 await chrome.action.setBadgeText({ text: active.length ? `${Math.min(...active)}%` : "" });
5}
Execution context: the service worker, called after each poll. A percentage on the badge is visible without opening anything; the popup can show a full list with names and sizes by reading the same dl: keys. Clear the badge when nothing is active.
7. Clean up when downloads are erased
1chrome.downloads.onErased.addListener(async (id) => {
2 const { tracked = {} } = await chrome.storage.session.get("tracked");
3 if (!tracked[id]) return;
4 delete tracked[id];
5 await chrome.storage.session.set({ tracked });
6 await chrome.storage.session.remove(`dl:${id}`);
7});
Execution context: the service worker. Users clear the downloads list, and other extensions or browsingData can erase entries too. When a tracked download is erased, drop its tracking and progress keys so the popup does not show a ghost entry and polling stops. onErased fires only for entries removed from the list; the file on disk is unaffected, and downloads.removeFile is the separate call that deletes it.
Common mistakes
- Waiting for
bytesReceivedinonChanged. It never arrives; pollsearch. - Polling forever. Stop when no tracked download is in progress, or the worker never sleeps.
- Tracking every download. Record only downloads your feature started.
- Ignoring
totalBytes: 0. Unknown size is common; show indeterminate progress. - Retrying user cancellations. It overrides an explicit choice.
Cross-browser variation
- Chrome / Edge: full API;
downloads.downloadfrom the service worker works withhttp(s)anddata:URLs. - Firefox:
browser.downloadswith the same events andsearch; also supportsblob:URLs created in extension pages. Interrupt reason names are the same set. - Safari: the
downloadsAPI is not available. Trigger downloads from an extension page with an<a download>link, and do without progress tracking.
Verification
- Start a large download from the extension and confirm the badge and popup show increasing percentages.
- Disconnect the network mid-download: the state becomes
interruptedwithNETWORK_FAILEDand a resume option appears. - Reconnect and resume: the download continues from where it stopped.
- Stop the worker mid-download, then confirm polling restarts on the next event and progress continues.
FAQ
Why not use an alarm for polling?
Alarms cannot fire more than once a minute in packed Chrome builds. A one-second setInterval is fine because the download’s own activity keeps the worker alive.
Can I open the file when it completes?
chrome.downloads.open(id) requires a user gesture and the downloads.open permission. chrome.downloads.show(id) reveals it in the file manager.
How do I detect dangerous file warnings?
The delta’s danger field changes when the browser flags a file. The user must accept or discard it; extensions cannot override the warning.
Related
- Managing downloads from an extension — starting and naming downloads.
- Progress notifications for long tasks — showing progress outside the popup.
- Badge text, colour and count patterns — compact progress on the icon.
- Browser data APIs: bookmarks, history and downloads — the parent topic.