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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

A click that arrives after the worker restartedThe worker creates a deal notification and stores its data, then stops; twenty minutes later the user clicks; Chrome starts a fresh worker, top-level listeners register, onClicked fires with the ID, the handler reads the deal from storage and opens or focuses the product tab.UserService workerstoragesave deal:842 {url, title}create('deal-842') … worker stopsclick (20 min later) → new workerget deal:842focus or open product tab
IDs and storage carry context across restarts; memory does not.

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.

Routing notification eventsonClicked, onButtonClicked and onClosed all pass the notification ID to a router which splits on the prefix — deal, sync, reminder — and calls the matching handler with the entity ID and, for buttons, the button index.onClicked(id)body clickonButtonClicked(id, i)button 0 or 1onClosed(id, byUser)dismissedparse prefixdeal-842open / mutesync-statusopen sync pagereminder-17open item / snooze
One router keyed by ID prefix keeps every notification type consistent.

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.

What a notification click can openResponses to a notification click compared on whether they work from a click, need permissions, and suit which case: focusing a tab, opening an extension page, opening the popup, opening the side panel.ResponseWorks from clickNotesFocus or open a web tabYesFocus the window tooOpen an extension pageYestabs.create(getURL)action.openPopup()Version-dependentNeeds a focused windowsidePanel.open()OftenRequires user gesture context
Tabs and extension pages always work; the popup and side panel depend on gesture rules.

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, onButtonClicked and onClosed as described; up to two buttons.
  • Firefox: supports onClicked and onClosed; buttons and onButtonClicked are not supported, so put the primary action on the body click and alternatives in your UI.
  • Safari: no notifications API for extensions; use other surfaces.

Verification

  1. Create a notification, stop the worker from chrome://serviceworker-internals, then click it — the handler must run.
  2. Click when the target tab is already open in another window and confirm that window comes forward.
  3. Press each button and confirm the action and the clearing.
  4. 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.

Other UI/UX Patterns & Interactive Components Resources