Progress Notifications for Long Tasks
Report the progress of long-running extension tasks: the progress notification type, throttling updates, combining with the action badge, a cancel button, completion and failure states, and alternatives where progress notifications are unsupported.
Table of Contents
Exporting a library of 8,000 saved pages takes three minutes. The user clicks “Export”, the popup closes, and for three minutes nothing indicates anything is happening — so they click Export again, starting a second export. Long tasks need visible progress that survives the popup closing, and in an extension the natural places for it are a progress notification and the toolbar badge. Done carelessly, progress updates become their own problem: an update every item floods the notification system, and a notification that stays at “37%” after a crash misleads. This guide reports progress well. It belongs to notifications, badges and the action API.
The tools for showing progress
Chrome’s notifications API has a progress type: a basic notification with a progress bar whose progress field (0–100) you update with notifications.update. It suits tasks lasting tens of seconds to minutes that the user started explicitly. The toolbar badge — action.setBadgeText with a percentage or count — is subtler and always visible, and works where progress notifications are unavailable. For very long or resumable jobs, an extension page with a progress view is better, with the notification and badge linking to it. Whatever the surface, the job itself runs in the service worker or an offscreen document and persists its progress, so the display can be reconstructed after a restart.
Step-by-step: progress that informs
1. Start the job in the service worker
1// sw.js
2chrome.runtime.onMessage.addListener((msg) => {
3 if (msg.type === "start-export") startExport();
4});
5
6async function startExport() {
7 const { exportJob } = await chrome.storage.session.get("exportJob");
8 if (exportJob?.state === "running") return focusProgress(); // already running — don't start twice
9 const total = await countItems();
10 await chrome.storage.session.set({ exportJob: { state: "running", done: 0, total, startedAt: Date.now() } });
11 await showProgress(0, total);
12 runExport(total);
13}
Execution context: the service worker, triggered by a message from the popup. Recording the job state before starting makes a second click harmless: it focuses the existing progress instead of starting again. The popup can close immediately; see running work that outlives the popup.
2. Create the progress notification
1async function showProgress(done, total) {
2 await chrome.notifications.create("export-progress", {
3 type: "progress",
4 iconUrl: "icons/128.png",
5 title: chrome.i18n.getMessage("exportingTitle"),
6 message: chrome.i18n.getMessage("exportingMessage", [done.toLocaleString(), total.toLocaleString()]),
7 progress: Math.floor((done / total) * 100),
8 buttons: [{ title: chrome.i18n.getMessage("cancel") }],
9 silent: true,
10 requireInteraction: true,
11 });
12}
Execution context: the service worker. type: "progress" adds the bar; the message shows absolute counts, which feel more concrete than a percentage alone. silent avoids a sound on every update; requireInteraction keeps it on screen rather than letting it slide into the notification center while running. A Cancel button gives the user control.
3. Throttle updates
1let lastPaint = 0;
2async function reportProgress(done, total) {
3 await chrome.storage.session.set({ exportJob: { state: "running", done, total } });
4 const now = Date.now();
5 if (now - lastPaint < 500 && done < total) return;
6 lastPaint = now;
7 const percent = Math.floor((done / total) * 100);
8 const shown = await chrome.notifications.update("export-progress", {
9 progress: percent,
10 message: chrome.i18n.getMessage("exportingMessage", [done.toLocaleString(), total.toLocaleString()]),
11 });
12 chrome.action.setBadgeText({ text: `${percent}%` });
13 if (!shown) userHidProgress = true; // dismissed — keep the badge, don't recreate the notification
14}
Execution context: the service worker, called from the job loop. Persisting progress on every step keeps it accurate for recovery; painting at most twice a second keeps the notification system responsive. If the user dismissed the progress notification, update returns false; respect that and rely on the badge. See updating and clearing notifications.
4. Handle cancel
1chrome.notifications.onButtonClicked.addListener(async (id, index) => {
2 if (id === "export-progress" && index === 0) {
3 await chrome.storage.session.set({ exportJob: { state: "cancelling" } });
4 }
5});
6
7// inside the job loop
8for (const batch of batches) {
9 const { exportJob } = await chrome.storage.session.get("exportJob");
10 if (exportJob.state === "cancelling") return finish("cancelled");
11 await writeBatch(batch);
12 await reportProgress(done += batch.length, total);
13}
Execution context: the service worker. Cancellation is cooperative: the button sets a flag, and the job checks it between batches. Checking storage rather than a variable keeps cancellation working even if a listener runs in a newly started worker instance.
5. Finish with a clear result
1async function finish(state, detail) {
2 await chrome.storage.session.set({ exportJob: { state } });
3 await chrome.notifications.clear("export-progress");
4 chrome.action.setBadgeText({ text: "" });
5 if (state === "done") {
6 await chrome.notifications.create("export-done", {
7 type: "basic", iconUrl: "icons/128.png",
8 title: chrome.i18n.getMessage("exportDoneTitle"),
9 message: chrome.i18n.getMessage("exportDoneMessage", [detail.filename]),
10 buttons: [{ title: chrome.i18n.getMessage("showInFolder") }],
11 });
12 } else if (state === "failed") {
13 await chrome.notifications.create("export-failed", {
14 type: "basic", iconUrl: "icons/128.png", priority: 1,
15 title: chrome.i18n.getMessage("exportFailedTitle"),
16 message: detail.reason,
17 });
18 }
19}
Execution context: the service worker. Replacing the progress notification with a distinct result notification gives completion its own moment — with sound if appropriate — and a next action (“Show in folder” via downloads.show). Cancelled jobs need no notification; the user knows they cancelled.
6. Keep long jobs alive and resumable
The service worker can be stopped during a long job. Extension API calls and active work extend its lifetime, but design for interruption: process in batches, persist a cursor after each, and on startup check for a running job and resume from the cursor. See persisting job progress across worker restarts. If the job is CPU-heavy, move it to an offscreen document.
7. Recover the display after a restart
1chrome.runtime.onStartup.addListener(async () => {
2 const { exportJob } = await chrome.storage.session.get("exportJob");
3 if (exportJob?.state !== "running") await chrome.notifications.clear("export-progress");
4});
Execution context: the service worker. storage.session is cleared when the browser restarts, so a leftover progress notification after a restart describes a job that no longer exists — clear it.
Common mistakes
- Updating per item. Floods the notification system; throttle by time.
- No duplicate-start guard. Users click twice and run two jobs.
- Progress left at 37% after a crash. Reconcile on startup.
- Only a percentage. Show counts so progress feels concrete.
- Making cancel a separate page. Put it on the notification.
Cross-browser variation
- Chrome / Edge:
progresstype supported on all desktop platforms; on macOS the system may render it as a basic notification with text. - Firefox: only the
basictype renders fully andupdateis unsupported; use the badge and a progress page, and a single completion notification. - Safari: no notifications API; use the badge and an extension page.
Verification
- Start an export, close the popup and confirm progress updates in the notification and badge.
- Click Export again mid-job and confirm no second job starts.
- Press Cancel and confirm the job stops within one batch and the badge clears.
- Restart the browser mid-job and confirm no stale progress remains.
FAQ
How often should progress update?
Two to four times per second is plenty for a bar; once a second is fine for text.
What if the task’s total is unknown?
Show an indeterminate message (“Exported 1,240 items…”) without the bar, or use the badge with a count.
Should the progress notification make a sound?
No. Use silent: true for progress and reserve sound for completion or failure.
Can a progress notification show time remaining?
Yes — compute it from the rate so far and put it in the message, for example “About 2 minutes left”. Smooth the estimate over the last several updates to avoid jumpy numbers.
Should these notifications appear in focus or do-not-disturb modes?
No — operating systems suppress them by design, and an extension cannot override that. Make sure anything important is also visible in the extension’s own UI, such as the badge or a status line in the popup, so a suppressed notification never hides information the user needs.
Related
- Updating and clearing notifications — notification lifecycle.
- Badge text, colour and count patterns — badge progress.
- Tracking download progress and failures — download-specific progress.
- Notifications, badges and the action API — the parent topic.