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.
5. A signalling policy for the whole extension
The action button, its badge, its icon, its tooltip and system notifications are all ways of telling the user something, and they compete for the same attention. Extensions that use them well tend to have an explicit policy about which signal is used for what, rather than deciding feature by feature.
A policy that works for most extensions has four levels. Nothing is the default: an empty badge, the normal icon, a tooltip that simply names the extension. Ambient state — “active on this site”, “paused here” — goes in the icon or a short badge word, scoped to the tab it describes. Attention — new items worth looking at — is a neutral-coloured badge count that clears when the user looks. Action required — sync failing, signed out, a permission revoked — is an alert-coloured badge, set while the problem exists and cleared the moment it is resolved, plus at most one notification if the problem blocks the product entirely.
Written down, the policy makes review simple: any new feature that wants to notify the user has to say which level it belongs at, and most turn out to belong at “attention” or below. The details for each signal are in badge text, colour and count patterns and notification permissions and alert fatigue.
6. Keeping signals truthful
A signal is only useful if it is current, and every signal on the toolbar outlives the code that set it. Badge text, icons and titles persist in the browser after the service worker is evicted; a badge that said “3 new” an hour ago still says it, even if the items were read on another device.
The dependable approach is to derive every signal from stored state rather than setting it at the moment something happens. A single function computes badge text, colour, icon and title from what is in storage; it runs from a storage.onChanged listener, from onStartup, and from tab activation for per-tab signals. Any code path that changes the underlying state — the popup marking items read, a sync job finishing, a synced change arriving — updates the signals automatically, because it updates storage. Nothing has to remember to clear the badge.
The same function is the natural place to respect the policy above: it can decide, in one place, that a failing sync outranks a new-item count, and that a paused tab shows “off” regardless of anything else. Signals computed this way are consistent across every surface and every code path, which is the property users notice most when it is missing.
7. Testing what the toolbar shows
Toolbar signals are easy to leave untested because they are not part of any page, but they are fully readable from the service worker — getBadgeText, getBadgeBackgroundColor, getTitle and isEnabled all accept an optional tab id. That makes them straightforward to assert on in an end-to-end test.
1await sw.evaluate(() => chrome.storage.local.set({ unread: ["a", "b", "c"] }));
2await expect.poll(() => sw.evaluate(() => chrome.action.getBadgeText({}))).toBe("3");
Execution context: a Playwright test with the service worker fixture described in driving service worker state from a test. Writing state and asserting on the resulting signal tests the whole derivation path, including the onChanged listener, and catches the most common regression — a badge that stops updating after a refactor moves where state is written.
Cover the per-tab variants as well: activate a tab where the extension is paused and assert the badge reads “off” for that tab id and is empty for another. Per-tab signals are the ones most likely to leak between tabs after a change to how tab ids are tracked, and the test takes only a few lines once the fixture exists.
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.
Further guides in this topic
The guides below go deeper into specific notifications, badges and the action api problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Badge Text, Colour and Count Patterns — Use the toolbar badge well in MV3 — the four-character limit, formatting large counts, per-tab versus global badges, colour and contrast, and clearing stale badges after a worker restart.
- Drawing Dynamic Action Icons with OffscreenCanvas — Render toolbar icons at runtime from an MV3 service worker — OffscreenCanvas and ImageData, the 16/32 pixel sizes, device pixel ratio, theme-aware colours and caching the result.
- Enabling and Disabling the Toolbar Action per Tab — Grey out the toolbar button where the extension cannot work — action.enable/disable per tab, declarativeContent rules that need no host permission, and keeping state right across navigations.
- Notification Permissions and Alert Fatigue — Use chrome.notifications without training users to ignore them — the permission model, OS-level blocking, rate limits you should impose yourself, grouping, and a quiet-by-default settings design.
- Handling Notification Clicks and Buttons — Respond to chrome.notifications clicks and button presses in an MV3 service worker: top-level listeners that survive restarts, routing by notification ID, focusing existing tabs, opening the popup or side panel, and Firefox’s limits.
- 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.
- Setting the Action Title and Tooltip — Use chrome.action.setTitle to make the toolbar icon’s tooltip informative: global and per-tab titles, showing state and shortcuts, localisation, accessibility as the icon’s accessible name, and keeping titles in sync with the badge.
- Updating and Clearing Notifications — Manage the lifecycle of chrome.notifications in an MV3 extension: stable notification IDs, updating instead of stacking, clearing on action or timeout, requireInteraction, getAll after worker restarts, and the notification center.
- Web Notifications vs chrome.notifications — Choose between the Web Notifications API (registration.showNotification) and chrome.notifications in an MV3 extension: permissions, features, service worker support, click handling, cross-browser reach, and a wrapper for both.
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.