Which Browsers Support Web Push Notification Actions
Notification actions are supported by Chromium browsers on every platform and by Firefox on Android, but not by Firefox on the desktop and not by Safari on macOS or iOS — and the surrounding display options fragment even further.
Quick answer
Two action buttons is the practical ceiling everywhere actions works at all. Notification.maxActions reports the real number at runtime and is the only trustworthy signal. Everything else in the showNotification() options bag — image, badge, action icons, requireInteraction, renotify, silent, vibrate — is accepted by the API on almost every engine but rendered by only some of them, because the browser hands the final draw call to the host operating system’s notification centre.
| Option | Chrome / Edge desktop | Chrome Android | Firefox desktop | Firefox Android | Safari macOS | iOS installed web app |
|---|---|---|---|---|---|---|
actions |
2 | 2 | none | 2 | none | none |
Action icon |
ignored | rendered | n/a | rendered | n/a | n/a |
image |
rendered | rendered | ignored | ignored | ignored | ignored |
badge |
ignored | rendered (mono) | ignored | rendered | ignored | ignored |
requireInteraction |
honoured | ignored | ignored | ignored | ignored | ignored |
renotify |
honoured with tag |
honoured with tag |
ignored | partial | ignored | ignored |
silent |
honoured | honoured | honoured | honoured | ignored | ignored |
vibrate |
ignored | honoured | ignored | honoured | ignored | ignored |
tag |
honoured | honoured | honoured | honoured | honoured | honoured |
data |
honoured | honoured | honoured | honoured | honoured | honoured |
The two rows that always work are tag and data, because neither is drawn — one collapses duplicates in the notification store and the other rides along to your notificationclick handler. Everything that occupies pixels is negotiable.
Why the matrix is only half the story
showNotification() never throws for an unsupported option. The options dictionary is filtered by WebIDL, unknown members are discarded, and known-but-unsupported members are passed to a platform bridge that quietly declines to draw them. That is why a notification with two actions, a hero image and requireInteraction: true renders four different ways on Windows 11, macOS 15, Android 15 and GNOME without a single console warning. The specification gives the user agent explicit licence to substitute its own presentation, and every vendor uses that licence.
The split runs along engine and host lines. Chromium on Windows draws its own notification surface when the Windows Action Center integration is unavailable, which is why requireInteraction behaves like a sticky toast there but is politely dropped on Android, where the system tray already keeps notifications until dismissed. Chromium on macOS delegates to the native Notification Center, which has no concept of a hero image and only ever shows the app icon — so the same Chrome build renders image on Windows and drops it on macOS. Treat “Chrome supports X” as meaningless without naming the operating system.
Safari is the sharpest cliff. Web push on Safari 16.4 and later, on both macOS and iOS, delivers the title, body, tag and data, and shows your web app’s icon rather than the icon you pass. Action buttons, hero images and badge masks are not part of that surface. On iOS the notification only exists at all once the user has added the site to the Home Screen, which the Safari and iOS web push reference covers in detail. Design the message so it still reads correctly when the title and body are the only things that survive.
Detecting support at runtime
Notification.maxActions is a static read-only property on the Notification interface, available in both window and service worker scopes. It returns the maximum number of actions entries the platform will render. Chromium returns 2. Engines without action support either omit the property entirely or return 0. Read it once, cache it, and let it drive how many buttons you attach.
For the remaining options, probe Notification.prototype. Each display option has a matching getter on the prototype, so 'image' in Notification.prototype tells you whether the engine parses the member. This is weaker than maxActions — it proves the API surface exists, not that the host OS paints anything — but it reliably separates “Firefox will discard this” from “Chromium will try”.
// service-worker.js — capability-aware notification builder
function displayCapabilities() {
const N = self.Notification;
const proto = N && N.prototype;
return {
maxActions: N && typeof N.maxActions === 'number' ? N.maxActions : 0,
image: !!proto && 'image' in proto,
badge: !!proto && 'badge' in proto,
requireInteraction: !!proto && 'requireInteraction' in proto,
renotify: !!proto && 'renotify' in proto,
silent: !!proto && 'silent' in proto,
vibrate: !!proto && 'vibrate' in proto,
};
}
self.addEventListener('push', (event) => {
const msg = event.data ? event.data.json() : {};
const caps = displayCapabilities();
const options = {
body: msg.body,
tag: msg.tag || 'default',
data: { url: msg.url, campaignId: msg.campaignId },
icon: '/icons/notification-192.png',
};
const wanted = msg.actions || [];
if (caps.maxActions > 0 && wanted.length) {
options.actions = wanted.slice(0, caps.maxActions).map((a) => ({
action: a.action,
title: a.title,
icon: a.icon, // silently ignored on desktop Chromium
}));
} else if (wanted.length) {
// No buttons available: fold the primary CTA into the copy.
options.body = msg.body + ' — tap to ' + wanted[0].title.toLowerCase();
}
if (caps.badge) options.badge = '/icons/badge-96.png';
if (caps.image && msg.image) options.image = msg.image;
if (caps.requireInteraction && msg.priority === 'high') {
options.requireInteraction = true;
}
if (caps.renotify && msg.replaces) options.renotify = true;
if (caps.vibrate) options.vibrate = [200, 100, 200];
event.waitUntil(self.registration.showNotification(msg.title, options));
});
Note the slice(0, caps.maxActions) rather than a hardcoded slice(0, 2). If a future engine reports three, you get three for free; if a constrained shell reports one, you send the button that matters instead of having the platform truncate your array from an arbitrary end. Wiring the resulting action string back to the right window is a separate problem, handled in notificationclick handling.
Why user-agent sniffing fails here
Branching on navigator.userAgent is the single most common way teams get this wrong, and it fails for five independent reasons.
The brand does not determine the renderer. Edge, Opera, Brave, Vivaldi, Arc and Samsung Internet are all Chromium, all report Chrome tokens, and all inherit maxActions === 2 — but Samsung Internet composites the notification through One UI, which applies its own icon treatment. A regex for /Chrome/ catches all of them and tells you nothing about the paint path.
The same build behaves differently per OS. Chrome 140 on Windows honours requireInteraction and draws image; Chrome 140 on macOS honours neither, because the macOS Notification Center owns the surface. The user-agent string does carry the platform token, so in principle you could switch on both — which means maintaining a two-dimensional table of engine × OS that goes stale on every OS release.
User-agent reduction removed the resolution you need. Chromium’s UA reduction froze the minor version and collapsed the platform string, and Client Hints only return high-entropy values when you request them and the user’s privacy settings allow it. The signal you would need to sniff on has been deliberately blunted.
In-app browsers lie. Notifications inside an embedded WebView, an Electron shell or an in-app browser frequently do not surface at all even though the API is present, and the user-agent looks like an ordinary mobile browser. Capability probes at least reflect the actual binding.
Sniffing is not falsifiable at runtime. Notification.maxActions is evaluated by the same code path that will render the notification. A user-agent regex is evaluated by your assumptions about that code path from months earlier. When the two disagree, the regex is wrong and there is nothing in the runtime to correct it.
Per-option notes worth knowing
Action icons are a Chromium-on-Android feature. Desktop Chromium parses the icon member of each action entry and then draws a text-only button. Supply the icon anyway — it costs one URL and improves the Android render — but never encode meaning that exists only in the glyph.
requireInteraction keeps a desktop toast on screen until the user acts. It has no effect on Android, where the tray is already persistent, and none on Safari. Do not use it as a delivery guarantee; use it sparingly for genuinely blocking events, because a toast that will not go away is the fastest route to a revoked permission.
renotify only means anything alongside a tag. Setting renotify: true without a tag is a TypeError in Chromium. With a tag, it re-alerts the user when a notification replaces an earlier one carrying the same tag — useful for a live score, hostile for a chat app that updates every few seconds.
silent suppresses sound and vibration. Note that a silent notification is still a visible notification: it does not exempt you from the userVisibleOnly: true contract you accepted at subscribe time, and Chromium still counts it against the budget it uses to decide whether your origin may push without displaying anything.
vibrate requires vibration hardware and a foreground-permissible context; desktop engines accept the array and discard it. Keep patterns short — long patterns are clamped, and an aggressive buzz is indistinguishable from spam to the user.
Gotchas and edge cases
maxActionscan be0rather than absent. Testtypeof Notification.maxActions === 'number'before comparing, otherwiseundefined > 0quietly evaluates false and you take the fallback branch on engines that would have rendered nothing anyway — correct by luck, not by design.- Extra actions are truncated, not rejected. Passing three entries to a
maxActions === 2platform drops the third with no error. Slice on your side so you control which one is lost. - Action
titlestrings are aggressively elided. Two buttons share one notification width; anything past roughly 12 characters is truncated on a narrow phone. Write verbs, not sentences. - A notification that renders nowhere still fires
push. Your handler runs, the budget is spent, and the analytics event you emit in the handler records a delivery the user never saw — the gap explored in cross-browser notification quirks. - Payload size still binds. Actions, titles, icon URLs and
dataall travel inside the same encrypted body, which must respect the 4 KBaes128gcmlimit from RFC 8291. Two action definitions with long URLs consume more of that budget than teams expect.
Related
- Web Push Browser Compatibility Reference — the wider support matrix covering VAPID, payload limits and subscription APIs.
- Adding Action Buttons to Web Push Notifications — the implementation guide for the two-button pattern this page scopes.
- Notification Icon and Badge Rendering Differences — why the assets you attach look different on each platform.
- Safari & iOS Web Push Integration — the most constrained surface in the matrix above.
Back to Web Push Browser Compatibility Reference
FAQ
Can a web push notification have more than two action buttons?
Not on any shipping engine. Chromium reports 2 from Notification.maxActions on every platform, and no other engine reports a higher number. Read the property instead of hardcoding 2, slice your action array to that value, and design the message so the most important action is first — a platform that renders fewer buttons will always keep the earlier entries.
Does Safari support notification action buttons on macOS or iOS?
No. Safari 16.4 and later support web push on macOS and on iOS for sites added to the Home Screen, but the notification surface is limited to the title, body, tag and data payload, plus the web app’s own icon. Action buttons, hero images and badge masks are not rendered. Fold the call to action into the body copy and rely on the notification click itself.
Why does my notification look different in Chrome on Windows and Chrome on macOS?
Because the operating system, not the browser, paints the notification. Chromium on Windows can draw its own surface with a hero image and a sticky toast, while on macOS it hands the notification to the system Notification Center, which shows only an icon, title and body. The same Chrome build therefore renders the same payload two different ways, which is why capability detection has to happen at runtime rather than from a version number.