Rich Notifications with Buttons and Images

Build MV3 notifications that work everywhere: the portable subset, why buttons and image types are not universal, routing clicks after worker eviction, and degrading gracefully.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

A notification with two action buttons and a preview image looks excellent in Chrome, loses the buttons in Safari, and in every engine can reach a user whose service worker was evicted an hour ago. Rich notifications are the most engine-divergent surface an extension has, and the portable subset is smaller than the API suggests. This page is part of Notifications, Badges & the Action API and covers building for the subset while enhancing where the engine allows.

Root cause: the API is uniform, the rendering is not

chrome.notifications.create accepts the same options everywhere, and quietly ignores what the platform cannot render. There is no capability query and no error — a type: "image" notification with two buttons resolves successfully on a platform that shows neither. So the design constraint is not “what does the API accept” but “what must still work when every optional part is dropped”.

What each engine actually rendersNotification types, buttons, images, progress and requireInteraction support across the three engines.FeatureChrome 120+Firefox 121+Safari 17+type: basicYesYesYestype: imageYesYesNotype: listYesNoNotype: progressYesNoNobuttonsUp to 2Up to 2IgnoredrequireInteractionYesIgnoredIgnored
The only row supported everywhere is the basic type — everything else has to be an enhancement, never the only path.

Step 1. Build the basic notification first

Write the version that works everywhere, then add. The title and message have to carry the whole meaning on their own, because on some platform they will be all the user sees.

 1// notify.js
 2export async function notifyBasic({ id, title, message, path }) {
 3  return chrome.notifications.create(id, {
 4    type: "basic",
 5    iconUrl: chrome.runtime.getURL("icons/128.png"),   // packaged; a remote URL fails silently
 6    title,          // must stand alone
 7    message,        // must stand alone
 8    // contextMessage is rendered smaller where supported and dropped elsewhere: never essential.
 9    contextMessage: path,
10  });
11}

Execution context: Extension service worker. iconUrl must resolve to a packaged resource — Chrome fails a remote icon without an error, producing a notification with no icon and no explanation. Encoding the routing target in id rather than in a lookup table is deliberate and load-bearing: the click may arrive after several worker evictions, and the id is the only thing that survives.

Step 2. Enhance only where the enhancement is reachable

Attach buttons only when the engine exposes the event that reports a click on them, and always provide the same action from the notification body so the button is never the only route.

 1// notify.js
 2const supportsButtons = typeof chrome.notifications.onButtonClicked !== "undefined";
 3
 4export async function notifyWithAction({ id, title, message, action }) {
 5  const options = {
 6    type: "basic",
 7    iconUrl: chrome.runtime.getURL("icons/128.png"),
 8    title,
 9    message,
10  };
11
12  if (action && supportsButtons) options.buttons = [{ title: action.label }];
13
14  await chrome.notifications.create(id, options);
15  // The body click does the same thing, so a dropped button costs the user nothing.
16  return id;
17}

Execution context: Extension service worker. Feature-detecting on the event rather than on a user-agent string keeps the check honest: if an engine adds button support later, the code picks it up with no change. The invariant worth stating explicitly is that the body click and the button do the same thing — the button is a shortcut, never the only path.

Step 3. Route the click after an eviction

Notification clicks are the classic late event: the notification can sit in the tray for an hour, long after the worker that created it has gone. Both handlers must be registered at the top level, and neither may depend on anything in memory.

 1// background.js — top-level registration, id-encoded routing
 2function routeFor(notificationId) {
 3  const [kind, arg] = notificationId.split(":");
 4  switch (kind) {
 5    case "import-done": return chrome.runtime.getURL("queue.html");
 6    case "item":        return chrome.runtime.getURL(`item.html?id=${encodeURIComponent(arg ?? "")}`);
 7    default:            return null;
 8  }
 9}
10
11async function openAndClear(notificationId) {
12  const url = routeFor(notificationId);
13  if (url) await chrome.tabs.create({ url });
14  await chrome.notifications.clear(notificationId);
15}
16
17chrome.notifications.onClicked.addListener((id) => { void openAndClear(id); });
18chrome.notifications.onButtonClicked.addListener((id, index) => {
19  if (index === 0) void openAndClear(id);
20});

Execution context: Extension service worker, both listeners registered during synchronous evaluation. Deriving the destination from the id means the worker needs no memory of having created the notification, which is what makes the handler correct after an arbitrary delay. Clearing the notification after acting on it prevents a second click reopening a duplicate tab.

A notification click arriving an hour laterThe worker creates the notification and is evicted; the click much later wakes a fresh worker, which routes from the notification id alone.Background jobNotification trayNew workerTabcreate('item:8213', …)worker evictedonClicked('item:8213')cold startrouteFor(id)tabs.create(url)notifications.clear(id)
Nothing in memory bridges the gap — the id is the entire state that survives.

Step 4. Treat delivery as unconfirmed

create resolving means the browser accepted the request, not that anything appeared. The user may have muted your extension, the operating system may be in a focus mode, or the platform may have collapsed several notifications into one. None of that is visible to your code.

 1// background.js — a durable record, independent of whether the notification was seen
 2export async function announce(item) {
 3  const id = `item:${item.id}`;
 4
 5  // Record it first. The queue is what the user can always come back to.
 6  const { pending = [] } = await chrome.storage.local.get("pending");
 7  await chrome.storage.local.set({ pending: [...pending, item] });
 8  await renderBadge();
 9
10  // Then try to notify. A failure here changes nothing that matters.
11  try {
12    await notifyWithAction({ id, title: "New item", message: item.title, action: { label: "Open" } });
13  } catch (error) {
14    console.warn("notification not shown", error);
15  }
16}

Execution context: Extension service worker. Writing the durable record before attempting the notification is the whole pattern: the badge and the queue are the reliable channel, and the notification is an optional accelerator on top. That ordering also means a user who has muted notifications entirely still sees the count, which is the accessible and respectful default.

Step 5. Rate-limit before the platform does it for you

Every desktop platform collapses or suppresses notifications from a source that produces too many, and it does so without telling the extension. A background job that announces each of thirty synced items produces one visible notification and twenty-nine invisible ones — so the aggregation has to happen in your code.

Aggregating a burst into one notificationIndividual results are queued and counted; a single summary notification is created after the burst settles, rather than one per item.30 items arriveone sync runQueue + countstorage, badgeWait for the burst to settlea few secondsthen one notification describes the whole batchOne summary"30 new items"Click opens the queuenot a single item
One summary the user reads beats thirty notifications the platform silently drops.
 1// background.js — collapse a burst into a single summary
 2let burst = 0;
 3let burstTimer = null;
 4
 5export function announceOne(item) {
 6  burst += 1;
 7  clearTimeout(burstTimer);
 8  burstTimer = setTimeout(() => { void announceBurst(); }, 3000);
 9}
10
11async function announceBurst() {
12  const count = burst;
13  burst = 0;
14  if (count === 0) return;
15
16  await notifyBasic({
17    id: `batch:${count}`,
18    title: count === 1 ? "New item" : `${count} new items`,
19    message: count === 1 ? "One item is waiting for review." : "Open the queue to review them.",
20  });
21}

Execution context: Extension service worker. A three-second settle window is long enough to absorb a sync run and short enough that the notification still feels connected to the action that caused it. Because the badge and queue were already updated per item, an aggregated notification loses no information — it only changes how loudly the same fact is announced.

Cross-browser variation

  • Chrome 120+: the widest surface — four types, two buttons, requireInteraction, and onButtonClicked. Remote iconUrl values fail silently.
  • Firefox 121+: basic and image types with buttons; list, progress and requireInteraction are ignored. Buttons render as menu items on some desktop environments.
  • Safari 17+: basic only, routed through the system notification centre. Buttons are ignored entirely, so the body click must carry the action.
  • All engines: operating-system-level muting is invisible to the extension, and create still resolves. Never treat a resolved promise as delivery.

Verification

  1. Create a notification with two buttons in each engine and note what renders. The body click must perform the primary action everywhere.
  2. Stop the worker, wait for eviction, then click a notification created earlier. The route must resolve from the id with no stored map.
  3. Mute the extension’s notifications at the OS level and confirm the badge and queue still update.
  4. Point iconUrl at a remote URL and confirm the notification appears without an icon — proof of why packaged assets matter.

FAQ

Why does my notification have no icon?

Almost always a remote or unresolvable iconUrl. Use chrome.runtime.getURL with a packaged asset; a remote URL is refused and the failure is silent.

Can I update a notification instead of creating a new one?

Yes, with chrome.notifications.update and the same id. On platforms that do not support update semantics the change may not appear, so never rely on an update to convey new information — create a fresh notification when the message genuinely changed.

How many buttons can I add?

Two at most where buttons are supported at all, and zero on Safari. Design for zero and treat any button as a shortcut to something the body click also does.

Does a notification keep the service worker alive?

No. The notification lives in the browser or the operating system; the worker is evicted normally and woken again by the click. That is why the routing has to be derivable from the id.

Other UI/UX Patterns & Interactive Components Resources