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.
Table of Contents
- Root cause: the API is uniform, the rendering is not
- Step 1. Build the basic notification first
- Step 2. Enhance only where the enhancement is reachable
- Step 3. Route the click after an eviction
- Step 4. Treat delivery as unconfirmed
- Step 5. Rate-limit before the platform does it for you
- Cross-browser variation
- Verification
- FAQ
- Related
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”.
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.
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.
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, andonButtonClicked. RemoteiconUrlvalues fail silently. - Firefox 121+:
basicandimagetypes with buttons;list,progressandrequireInteractionare ignored. Buttons render as menu items on some desktop environments. - Safari 17+:
basiconly, 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
createstill resolves. Never treat a resolved promise as delivery.
Verification
- Create a notification with two buttons in each engine and note what renders. The body click must perform the primary action everywhere.
- Stop the worker, wait for eviction, then click a notification created earlier. The route must resolve from the id with no stored map.
- Mute the extension’s notifications at the OS level and confirm the badge and queue still update.
- Point
iconUrlat 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.
Related
- Updating the toolbar badge from the service worker — the quieter channel that always works.
- Notifications, Badges & the Action API — the guide this page belongs to.
- Opening a popup from the service worker — the other way a worker asks for attention.
- Cross-browser API compatibility — the wider divergence picture.