Notifications, Badges & the Action API
Signal state from an MV3 service worker with chrome.action badges, icons and titles plus chrome.notifications — including per-tab state, click routing, and Chrome, Firefox and Safari differences.
A service worker cannot open a window, cannot focus a tab unprompted, and cannot show UI of its own — so when background work produces something the user should know about, the toolbar button and the notification tray are the whole vocabulary. Both are cheap, both survive worker eviction because the browser holds the state, and both are easy to get subtly wrong: a badge set without a tab id changes every tab, and a notification handler registered lazily never fires. This guide is part of UI/UX Patterns & Interactive Components and covers the passive signals first, then the interruptive ones.
Prerequisites checklist
- An
"action"key inmanifest.jsonwith at least a default icon. Badge and title calls target the action, so without it there is nothing to write to. -
"notifications"permission only if you actually create notifications. It is visible to users at install time, so do not declare it speculatively. - Top-level listeners for
chrome.action.onClickedandchrome.notifications.onClickedin the service worker, registered synchronously. - Icons at multiple densities — 16, 32, 48 and 128 pixels. The browser picks per display, and a missing size is upscaled visibly.
-
"storage"permission if badge state must be restored after eviction; the browser keeps badge text, but your own counters live in chrome.storage.
Manifest registration
1{
2 "manifest_version": 3,
3 "name": "Review Queue",
4 "version": "4.2.0",
5
6 "action": {
7 "default_title": "Review Queue", // hover text; overridden per tab at runtime
8 "default_icon": { // all four densities, or the browser upscales
9 "16": "icons/16.png",
10 "32": "icons/32.png",
11 "48": "icons/48.png",
12 "128": "icons/128.png"
13 }
14 // No default_popup here: this extension answers a click in the worker instead.
15 // Declaring default_popup DISABLES action.onClicked entirely.
16 },
17
18 "permissions": [
19 "notifications", // only because this extension really does notify
20 "storage",
21 "alarms"
22 ],
23
24 "background": { "service_worker": "background.js", "type": "module" }
25}
Execution context: Parsed at install time. The comment about default_popup is the single most common surprise in this API: with a popup declared, clicking the toolbar button opens the popup and chrome.action.onClicked never fires, so an extension cannot have both. Firefox exposes the same key as action under MV3; Safari accepts it but ignores default_icon densities above 128.
1. Badges: global state versus per-tab state
chrome.action.setBadgeText({ text }) sets the badge for every tab. Passing a tabId scopes it to that tab, and per-tab values override the global one. Mixing the two without deciding which is authoritative produces a badge that appears to change at random as the user switches tabs.
1// background.js
2async function setGlobalCount(count) {
3 await chrome.action.setBadgeText({ text: count > 0 ? String(count) : '' });
4 await chrome.action.setBadgeBackgroundColor({ color: count > 0 ? '#b45309' : '#475569' });
5}
6
7async function setTabCount(tabId, count) {
8 // Scoped to one tab; survives navigation within that tab but not tab close.
9 await chrome.action.setBadgeText({ tabId, text: count > 0 ? String(count) : '' });
10}
11
12// Clearing a per-tab override falls back to whatever the global value is.
13async function clearTabOverride(tabId) {
14 await chrome.action.setBadgeText({ tabId, text: null });
15}
Execution context: Extension service worker. All three calls are promise-returning in MV3 and none of them require a permission — the action is always yours to write to. An empty string hides the badge; null with a tabId removes the override rather than hiding the badge, which is the distinction that makes per-tab badges usable. Badge text is held by the browser, so it persists across worker eviction; a tab-scoped value is discarded when that tab closes. Firefox limits badge text to roughly four characters before truncating, so keep counts short and cap them.
2. Restoring the badge after a browser restart
Because the browser drops badge state when it restarts, the extension has to rebuild it — and the only reliable trigger is chrome.runtime.onStartup, which fires once when a profile carrying your extension starts.
1// background.js — rebuild the visible state from the durable state
2async function restoreBadge() {
3 const { pending = [] } = await chrome.storage.local.get('pending');
4 await setGlobalCount(pending.length);
5 await chrome.action.setTitle({
6 title: pending.length ? `Review Queue — ${pending.length} waiting` : 'Review Queue',
7 });
8}
9
10chrome.runtime.onStartup.addListener(restoreBadge);
11chrome.runtime.onInstalled.addListener(restoreBadge);
Execution context: Extension service worker, listeners registered at the top level. onStartup does not fire when the worker merely wakes from eviction — it is a per-browser-session event, which is exactly the granularity badge restoration needs. Registering onInstalled as well covers the install and update paths. Firefox fires both events with the same semantics; Safari fires onStartup less predictably, so treating a stale badge as cosmetic rather than critical keeps the feature honest.
3. Answering a toolbar click without a popup
With no default_popup, a click reaches the worker as chrome.action.onClicked with the tab it happened on. That tab argument is authoritative and free — it removes the need for the active tab query and the race that comes with it. A click also counts as a user gesture, which is what makes it the right place to request an optional permission or open a side panel.
1// background.js
2chrome.action.onClicked.addListener((tab) => {
3 (async () => {
4 // The gesture is live only until the first await — do gesture-bound work first.
5 const granted = await chrome.permissions.request({ origins: [`${new URL(tab.url).origin}/*`] });
6 if (!granted) {
7 await chrome.action.setBadgeText({ tabId: tab.id, text: '!' });
8 await chrome.action.setTitle({ tabId: tab.id, title: 'Access to this site was declined' });
9 return;
10 }
11
12 const [{ result }] = await chrome.scripting.executeScript({
13 target: { tabId: tab.id },
14 func: () => document.querySelectorAll('[data-review]').length,
15 });
16
17 await chrome.action.setBadgeText({ tabId: tab.id, text: result ? String(result) : '' });
18 })();
19});
Execution context: Extension service worker. tab is fully populated, including url, when the extension has access to that tab — without host access tab.url may be absent, so guard the new URL(...) call in production code. The permission request has to be the first statement that touches the gesture, as described in requesting optional permissions at runtime. Firefox and Safari both deliver onClicked with the tab; neither fires it when a popup is declared.
4. Notifications, and when they are the wrong answer
A notification interrupts. It is the right choice when the user asked for something that has now finished, and the wrong choice for anything periodic or informational — a badge covers those without ever taking focus. When you do notify, the click has to route somewhere useful, and the handler must be registered at the top level because a click can arrive long after the worker was evicted.
1// background.js
2async function notifyImportFinished(count) {
3 const id = `import-done:${Date.now()}`;
4 await chrome.notifications.create(id, {
5 type: 'basic',
6 iconUrl: chrome.runtime.getURL('icons/128.png'), // must be an extension URL
7 title: 'Import finished',
8 message: `${count} records added to your queue.`,
9 buttons: [{ title: 'Open queue' }], // Chrome and Firefox; ignored in Safari
10 requireInteraction: false, // true keeps it on screen until dismissed
11 });
12}
13
14// Registered at the top level: the click may arrive after several evictions.
15chrome.notifications.onButtonClicked.addListener((notificationId, buttonIndex) => {
16 (async () => {
17 if (!notificationId.startsWith('import-done:') || buttonIndex !== 0) return;
18 await chrome.tabs.create({ url: chrome.runtime.getURL('queue.html') });
19 await chrome.notifications.clear(notificationId);
20 })();
21});
22
23chrome.notifications.onClicked.addListener((notificationId) => {
24 (async () => {
25 await chrome.tabs.create({ url: chrome.runtime.getURL('queue.html') });
26 await chrome.notifications.clear(notificationId);
27 })();
28});
Execution context: Extension service worker. iconUrl must resolve to a packaged resource — a remote URL fails silently in Chrome, which is a common reason a notification appears without its icon. Encoding routing information in the notification id (rather than a module-scope map) is what makes the click handler work across evictions. buttons are supported in Chrome and Firefox but ignored by Safari, so never put the only path to an action behind a button; requireInteraction is Chrome-only. On every engine the user can disable your notifications entirely at the OS level, and create still resolves — so treat delivery as unconfirmed.
MV3 constraints to design around
- A declared popup disables
onClicked. You get one or the other, decided in the manifest. - Badge and icon state does not survive a browser restart. Rebuild it from
onStartup. iconUrlmust be a packaged resource. Remote icons fail, usually without an error.- Notification buttons are not portable. Chrome and Firefox honour them; Safari does not.
- Delivery is never guaranteed. OS-level and browser-level muting are invisible to your code.
- Per-tab overrides die with the tab. They are not persisted anywhere.
Cross-browser notes
The action API is close to identical across engines under MV3; notifications are where the divergence lives. Chrome supports basic, image, list and progress types plus buttons and requireInteraction; Firefox supports basic and image with buttons; Safari supports basic only and routes through the system notification centre. A capability-aware wrapper keeps the feature working everywhere without three code paths:
1const api = (typeof browser !== 'undefined' ? browser : chrome);
2
3export async function notify(title: string, message: string, action?: { title: string }) {
4 const options: chrome.notifications.NotificationOptions = {
5 type: 'basic',
6 iconUrl: api.runtime.getURL('icons/128.png'),
7 title,
8 message,
9 };
10 // Only attach a button where the engine exposes the event that would report a click on it —
11 // an ignored button is a dead end for the user.
12 const supportsButtons = typeof api.notifications.onButtonClicked !== 'undefined';
13 if (action && supportsButtons) options.buttons = [action];
14 return api.notifications.create(options);
15}
Execution context: Shared module imported by the background context. Because the same action must also be reachable without the button, the wrapper always registers an onClicked route to the same destination. For the wider namespace picture see cross-browser API compatibility.
Related
- Updating the toolbar badge from the service worker — counter state that survives eviction.
- Rich notifications with buttons and images — the portable subset and the fallbacks.
- Popup Interface Design — the alternative to answering a click in the worker.
- Side Panel & DevTools Interfaces — for state the user needs to keep visible.
- UI/UX Patterns & Interactive Components — the section this guide belongs to.