Adding Action Buttons to Web Push Notifications
Action buttons turn a notification from an announcement into a control surface — but the actions array is the single most inconsistently honoured field in NotificationOptions, and building a flow that depends on it is how you lose a third of your audience silently.
Quick answer
Pass an array of { action, title, icon } objects to showNotification(), cap its length at Notification.maxActions, and route on event.action in your notificationclick handler. In practice only the first two entries render anywhere, action icon is drawn by Chromium desktop and ignored everywhere else, and Safari plus Firefox before version 152 drop the array entirely without an error. Every action must therefore be a shortcut to something the plain body tap already achieves.
| Question | Short answer |
|---|---|
| How many buttons render? | Two. Notification.maxActions is 2 on every shipping Chromium and Firefox build. |
| What happens to extras? | Silently discarded at display time — no exception, no console warning. |
| Do action icons work? | Chromium desktop only. Android, Firefox and Safari ignore icon. |
| What does the body tap send? | event.action === ''. Always a valid, distinct route. |
| Where does the cost land? | Inside the same 4 KB encrypted record as the rest of the payload. |
Feature-detect before you build
Notification.maxActions is a static read-only property on the Notification interface, readable from both window and service worker scopes. It reports how many actions the platform will actually draw. Where the property is absent, the engine has no action support at all — that is your detection signal, not a user-agent string.
Read it at display time inside the service worker, not at subscribe time in the page. The value belongs to the platform rendering the card, and a user who upgrades Firefox from 151 to 152 between subscribing and receiving gains action support without re-subscribing.
// Inside sw.js — clamp to what this platform will actually draw.
const MAX = (self.Notification && Notification.maxActions) || 0;
function buildActions(requested) {
if (!Array.isArray(requested) || MAX === 0) return [];
return requested.slice(0, MAX).map((a) => ({
action: String(a.action).slice(0, 32), // your routing id
title: String(a.title).slice(0, 20), // the visible label
icon: a.icon // Chromium desktop only
}));
}
Order the array by importance, because truncation always takes from the tail. If your payload carries ['approve', 'reject', 'snooze'] and the platform draws two, the user never sees “snooze” — so the third action must never be the only path to a necessary outcome.
Action icons are a Chromium desktop feature
Each entry may carry an icon URL. Chromium on Windows, macOS and Linux draws it as a small monochrome-ish glyph to the left of the label. Chromium on Android does not — the platform notification style has no room for it. Firefox parses the field and discards it. Safari has no action support at all, so the question does not arise. There is no runtime flag that tells you whether icons will be drawn; assume they will not.
Icons must be same-origin or CORS-readable over HTTPS, and they are fetched by the browser at display time, which means a slow or 404-ing icon URL delays the card. Ship small, cached, absolute-path assets (/icons/reply-64.png), not remote CDN URLs resolved per notification.
title is the load-bearing field. Keep it under about 20 characters — Chromium desktop truncates around there and Android uppercases the label, which eats horizontal space. “Reply” and “Snooze” work; “Reply to this message” does not.
action and title are dependable; treat icon as an enhancement for one platform family.Routing event.action in the click handler
Every action button and the card body funnel into the same notificationclick listener. The discriminator is event.action: the empty string for the body, the exact action id for a button. Build a routing table rather than an if/else ladder — it keeps the empty-string case explicit and makes an unknown action fail into a sane default instead of doing nothing.
Some actions should not navigate at all. “Snooze” or “Mark read” typically means POST to your API, optionally re-show a notification, and leave the browser where it is. Those still need event.waitUntil(), because the worker will be torn down the instant the handler returns.
self.addEventListener('notificationclick', (event) => {
const data = event.notification.data || {};
event.notification.close();
const handlers = {
// Body tap. The empty string is a real route, not a missing value.
'': () => openTarget(data.url || '/'),
'view': () => openTarget(data.url || '/'),
'reply': () => openTarget(`/messages/${data.messageId}?compose=1`),
'snooze': async () => {
await fetch('/api/snooze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messageId: data.messageId, minutes: 30 })
});
// Re-show so the push still ends in a visible notification.
await self.registration.showNotification('Snoozed for 30 minutes', {
body: 'We will remind you again shortly.',
tag: `snooze-${data.messageId}`,
icon: '/icons/notification-192.png'
});
}
};
const run = handlers[event.action] || handlers[''];
event.waitUntil(run());
});
async function openTarget(url) {
const target = new URL(url, self.location.origin);
const clients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
const match = clients.find((c) => new URL(c.url).pathname === target.pathname);
if (match) return match.focus();
return self.clients.openWindow(target.href);
}
The openTarget() helper is deliberately conservative about matching. Getting that comparison wrong is the most common bug in this whole area and has its own walkthrough on notificationclick opening the wrong window. The lifetime rules that make the waitUntil() wrapper mandatory are covered in the parent notification display and actions guide.
Actions cost bytes inside the 4 KB budget
Actions are not free. They travel in the same encrypted push payload as everything else, and RFC 8291 caps the aes128gcm record at 4096 bytes including framing — exceed it and the push service rejects the request with 413 Payload Too Large. Two actions with short ids, short titles and relative icon paths cost roughly 200 bytes of JSON. Four actions with absolute HTTPS icon URLs on a CDN hostname can cost three times that, and the extra two never render.
The mitigation is to stop shipping presentation in the payload. Send an action code and expand it to a full { action, title, icon } object inside the service worker from a table baked into sw.js. The payload carries "a":["reply","snooze"]; the worker turns that into the full array. You also get localisation for free, because the worker can pick titles from self.registration.scope or a cached translation bundle.
Compression does not save you here either: the 4096-byte limit applies to the encrypted record, after your JSON has been serialised, so trimming has to happen before encryption. The overhead accounting is spelled out in Push API Payload Encryption.
Degrading where actions do not exist
Graceful degradation is not a fallback branch you add later — it is the constraint the notification is designed under. The rule: the body tap must already deliver the primary outcome, and every action button must be a shortcut to something reachable in one more tap after that.
For an approval notification, the body opens the approval screen; approve and reject are shortcuts. For a message, the body opens the thread; reply focuses the composer. A Safari user, who sees no buttons at all, loses convenience but never capability. If you cannot describe your notification that way, the interaction belongs in the page, not the tray.
When actions are unavailable, spend the freed budget on clarity: a slightly longer body that states the choice, and a data.url that deep-links straight to the decision. Engine-by-engine support thresholds are tracked in which browsers support web push notification actions, and the wider normalisation strategy in the cross-browser notification quirks guide.
Gotchas and edge cases
actionids must be unique within one notification. Duplicate ids makeevent.actionambiguous, and Chromium keeps only the first.- An empty-string
actionid is not allowed — it collides with the body-tap signal and makes the click unroutable. Always use a real id. maxActionsisundefined, not0, on engines with no support.Notification.maxActions || 0is the safe read; a bare truthiness check on the property object will mislead you.- Action clicks do not automatically dismiss the notification. Call
event.notification.close()yourself, or the card stays on screen on desktop Chromium and the user taps it again. - A button that only calls an API still needs a visible outcome. Re-show a short confirmation notification, or the user cannot tell whether the tap registered.
- Do not localise action titles on the server per subscriber. It multiplies payload variants and defeats collapse keys; expand codes in the worker instead.
Related
- Notification click not opening the right window — fixing the focus-versus-open decision your action routes depend on.
- Push API Payload Encryption — the 4 KB
aes128gcmaccounting that action metadata eats into. - Which browsers support web push notification actions — the version thresholds behind the slot chart above.
Back to Notification Display & Actions API
FAQ
Why do only two of my three action buttons appear?
Notification.maxActions is 2 on every shipping Chromium and Firefox build, and the platform silently discards anything past that index at display time — no exception is thrown and nothing is logged. Slice your array to Notification.maxActions before calling showNotification() and order it so the two most important actions are first.
Why are my action icons not showing on Android?
The icon field on a NotificationAction is drawn only by Chromium on desktop. Chromium on Android renders action buttons as uppercased text labels with no glyph, Firefox parses and discards the field, and Safari has no action support at all. Design labels that read correctly with no icon.
How do I tell whether the user tapped a button or the notification body?
Read event.action in your notificationclick handler. It is the empty string for a body tap and the exact action id string for a button. Route the empty string explicitly as its own case rather than letting it fall through a falsy check, so an unknown action id can safely default to the same destination.