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.
Table of Contents
The notification says “Price dropped: Noise-cancelling headphones — View deal”. The user clicks it twenty minutes later, and nothing happens. The service worker that created the notification stopped long ago; when the click woke it, the onClicked listener had been registered inside the function that created the notification, so the new worker instance never registered it, and the event went nowhere. Notification events outlive the code that caused them, which makes them a classic MV3 lifecycle trap. This guide wires click, button and close events correctly, routes them by ID, and opens the right UI in response. It belongs to notifications, badges and the action API.
The three notification events
chrome.notifications.onClicked(notificationId) fires when the user clicks the body of a notification. onButtonClicked(notificationId, buttonIndex) fires for one of up to two buttons defined in the buttons option. onClosed(notificationId, byUser) fires when it is dismissed or cleared. All three are delivered to the service worker, waking it if needed. Because the worker may have restarted any number of times since the notification was created, listeners must be registered synchronously at the top level of the worker script, and handlers must get everything they need from the notification ID or from storage — never from in-memory variables set when the notification was created.
Step-by-step: reliable notification handlers
1. Encode the target in the notification ID
1// sw.js
2async function notifyDeal(deal) {
3 await chrome.storage.local.set({ [`deal:${deal.id}`]: { url: deal.url, title: deal.title } });
4 await chrome.notifications.create(`deal-${deal.id}`, {
5 type: "basic",
6 iconUrl: "icons/128.png",
7 title: chrome.i18n.getMessage("dealTitle"),
8 message: deal.title,
9 buttons: [{ title: chrome.i18n.getMessage("viewDeal") }, { title: chrome.i18n.getMessage("muteItem") }],
10 });
11}
Execution context: the service worker. The ID deal-842 tells any future handler what this notification is about; the stored record holds details too large or sensitive for the ID. Button titles are localised. See updating and clearing notifications for choosing IDs.
2. Register listeners at the top level
1// sw.js — top level, not inside any function or promise
2chrome.notifications.onClicked.addListener(handleClick);
3chrome.notifications.onButtonClicked.addListener(handleButton);
4chrome.notifications.onClosed.addListener(handleClosed);
Execution context: the service worker’s top level. Chrome dispatches the event to listeners registered during the worker’s first synchronous run. Listeners added later — after an await, inside notifyDeal, or in an onInstalled handler — are missing whenever the worker starts because of the click.
3. Route by prefix
1const ROUTES = {
2 deal: {
3 click: (id) => openDeal(id),
4 buttons: [(id) => openDeal(id), (id) => muteItem(id)],
5 },
6 reminder: {
7 click: (id) => openItem(id),
8 buttons: [(id) => openItem(id), (id) => snooze(id, 60)],
9 },
10};
11
12function parse(notificationId) {
13 const i = notificationId.indexOf("-");
14 return [notificationId.slice(0, i), notificationId.slice(i + 1)];
15}
16
17async function handleClick(nid) {
18 const [kind, id] = parse(nid);
19 await ROUTES[kind]?.click(id);
20 await chrome.notifications.clear(nid);
21}
22
23async function handleButton(nid, index) {
24 const [kind, id] = parse(nid);
25 await ROUTES[kind]?.buttons[index]?.(id);
26 await chrome.notifications.clear(nid);
27}
Execution context: the service worker. A table keyed by prefix scales to many notification kinds and keeps button order next to the handler that interprets it. Clearing after handling removes the notification from the notification center, which some platforms do not do automatically on click.
4. Focus an existing tab instead of opening duplicates
1async function openDeal(id) {
2 const { [`deal:${id}`]: deal } = await chrome.storage.local.get(`deal:${id}`);
3 if (!deal) return chrome.tabs.create({ url: chrome.runtime.getURL("deals.html") });
4 const [existing] = await chrome.tabs.query({ url: deal.url });
5 if (existing) {
6 await chrome.tabs.update(existing.id, { active: true });
7 await chrome.windows.update(existing.windowId, { focused: true });
8 } else {
9 await chrome.tabs.create({ url: deal.url });
10 }
11}
Execution context: the service worker. Clicking a notification typically comes from outside the browser window, so focusing the window matters as well as activating the tab. tabs.query by URL requires host permission for that URL or the tabs permission; without them, open a new tab. Fall back to a sensible page if the stored record is gone.
5. Open extension UI when there is no web target
1async function openItem(id) {
2 const url = chrome.runtime.getURL(`library.html#item=${encodeURIComponent(id)}`);
3 const [tab] = await chrome.tabs.query({ url: chrome.runtime.getURL("library.html*") });
4 if (tab) {
5 await chrome.tabs.update(tab.id, { active: true, url });
6 await chrome.windows.update(tab.windowId, { focused: true });
7 } else {
8 await chrome.tabs.create({ url });
9 }
10}
Execution context: the service worker. Extension pages can always be opened from a notification. Reusing an open library tab avoids a new tab per click. Opening the toolbar popup with chrome.action.openPopup() is possible in recent Chrome versions but depends on having a focused browser window; a tab is the dependable choice.
6. Make buttons do what they say, immediately
Button presses should complete their action without further UI where possible — “Snooze 1 hour” schedules an alarm and clears the notification; “Mute item” saves the preference. Do not open a confirmation page for a button; if confirmation is needed, the action belongs in a full page instead. Keep button labels to two or three words, and never use more than two buttons.
7. Learn from closes
1async function handleClosed(nid, byUser) {
2 const [kind, id] = parse(nid);
3 if (kind === "deal") await chrome.storage.local.remove(`deal:${id}`);
4 if (byUser) await bumpDismissCount(kind);
5}
Execution context: the service worker. Clean up stored context when a notification is gone, so storage does not accumulate records for notifications that can never be clicked. Dismissal counts per kind feed back into how often you notify.
Common mistakes
- Listeners registered inside functions. The restarted worker never sees the event.
- Context held in memory. Lost when the worker stops; use the ID and storage.
- Opening a new tab per click. Focus the existing one.
- Not focusing the window. The tab activates behind other apps.
- Buttons in Firefox. Firefox does not show notification buttons.
Cross-browser variation
- Chrome / Edge:
onClicked,onButtonClickedandonClosedas described; up to two buttons. - Firefox: supports
onClickedandonClosed;buttonsandonButtonClickedare not supported, so put the primary action on the body click and alternatives in your UI. - Safari: no
notificationsAPI for extensions; use other surfaces.
Verification
- Create a notification, stop the worker from
chrome://serviceworker-internals, then click it — the handler must run. - Click when the target tab is already open in another window and confirm that window comes forward.
- Press each button and confirm the action and the clearing.
- Dismiss a notification and confirm its stored context is removed.
FAQ
Can I tell which part of the notification was clicked?
Only body versus buttons. There is no event for the icon or image.
Does a notification click count as a user gesture?
For many APIs, a click handler runs with user activation, but rules differ per API and version. Test any gesture-gated call such as sidePanel.open.
Can I add a text reply field?
No. Extension notifications support only buttons; collect input in a page.
Are clicks delivered if the browser was closed?
On platforms where notifications persist in the system center after the browser exits, clicking one may relaunch the browser; the worker then starts and receives the event if listeners are at the top level.
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 — IDs and lifecycle.
- Rich notifications with buttons and images — defining buttons.
- Rebuilding in-memory state after termination — why memory is not enough.
- Notifications, badges and the action API — the parent topic.