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 slots rendered per engine Four action slots across the top. Chromium desktop, Chromium Android and Firefox 152 or later each fill two slots and discard the remaining two. Firefox before 152 and Safari fill none. You supply four actions. Here is what the platform draws. actions[0] actions[1] actions[2] actions[3] Chromium desktop rendered rendered dropped dropped Chromium Android rendered rendered dropped dropped Firefox 152+ rendered rendered dropped dropped Firefox below 152 whole array ignored — maxActions is undefined Safari macOS / iOS no action support — body tap is the only target Truncation always takes from the tail, so order the array by importance. Nothing throws and nothing logs — the extra buttons simply never exist.
Two slots is the real ceiling. Treat anything past index 1 as decoration you will never see.

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 button rendering with and without the icon field Left, a Chromium desktop action button showing a small glyph before the label. Right, the same action on Android and Firefox rendered as an uppercased text label with no glyph. Below, a reference of the three NotificationAction fields. The same action object, two very different buttons Chromium desktop — action.icon drawn Reply glyph fetched at display time Android and Firefox — icon discarded REPLY label uppercased, space is tighter NotificationAction fields action required id — comes back as event.action, never shown title required label — keep under 20 characters icon optional URL — Chromium desktop only, silently ignored elsewhere Design the label so it reads correctly with the glyph missing.
Only 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.

Approximate byte cost of payload components Horizontal bars comparing the byte cost of aes128gcm framing, title and body, icon paths, the data object, two actions with relative icons, and four actions with absolute CDN icon URLs, which is by far the largest. Where the 4096-byte aes128gcm record actually goes approximate bytes per component of a typical payload aes128gcm framing ~120 B title + body text ~190 B icon + badge paths ~95 B data object ~160 B 2 actions, local icons ~210 B 4 actions, CDN icons ~640 B Two of those four buttons never render — you paid 430 bytes for nothing. Send action codes and expand them to full objects inside the service worker.
Action metadata is the easiest part of a payload to overspend on, and the least likely to be rendered.

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

  • action ids must be unique within one notification. Duplicate ids make event.action ambiguous, and Chromium keeps only the first.
  • An empty-string action id is not allowed — it collides with the body-tap signal and makes the click unroutable. Always use a real id.
  • maxActions is undefined, not 0, on engines with no support. Notification.maxActions || 0 is 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.

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.