Notification Display & Actions API
Everything between a decrypted push payload and a user tapping a notification happens inside two short-lived service worker invocations. This guide covers the whole surface: every showNotification() option and which engines honour it, the notificationclick and notificationclose handlers, event.action dispatch, round-tripping state through event.notification.data, the waitUntil() lifetime rules that decide whether your async work survives, and the mandatory-visible-notification rule that makes a silent push cost you the permission you spent months earning.
Prerequisites
From push event to pixels
The push event handler is not a normal callback. It runs inside a worker the browser started specifically to service this message, and the browser is entitled to kill that worker the moment your handler returns. event.waitUntil(promise) is the only mechanism that extends the worker’s life: it tells the browser “keep me alive until this promise settles”. Everything asynchronous — decoding the payload, reading IndexedDB, fetching an avatar, and crucially the showNotification() call itself — must be inside a promise passed to waitUntil().
Chromium grants roughly 30 seconds of wall time per push event and Firefox is comparable, but neither is a contract. Treat the budget as “a few seconds of network, not a synchronisation job”. A handler that awaits a slow API before deciding what to display will eventually be terminated mid-flight, and a terminated handler that never called showNotification() is treated as a silent push.
The click side is a second, separate wake-up with its own budget. By the time the user taps, the worker that showed the notification has almost certainly been shut down. The browser starts a fresh instance, replays your script from the top, and dispatches notificationclick into it. Any module-scope variable you set during the push event is gone; the only state that survives is what you serialised into event.notification.data or persisted to IndexedDB.
Anatomy of the rendered card
ServiceWorkerRegistration.showNotification(title, options) takes a required title string and an options dictionary. The platform, not your code, decides how those fields become pixels: Chromium on Windows draws a wide card with a hero image slot, Chromium on Android compresses the same payload into a collapsible system row, GNOME renders a minimal two-line toast, and macOS Safari maps the notification onto the native banner with no image and no action icons at all.
Design for the smallest renderer. A title over about 40 characters is truncated on Android before the body is even considered, and a body over roughly two lines is collapsed behind an expander that many users never open. Put the decision-critical information in the title and the first eight words of the body; treat image and actions as progressive enhancement rather than layout you depend on. Icon and badge sizing diverges enough between engines that it has its own reference on icon and badge rendering differences.
The complete options reference
Every field below is read from the NotificationOptions dictionary. Unsupported fields are ignored rather than throwing, which is why a payload that works on Chromium desktop can render as a bare two-line toast on macOS with no error anywhere in your logs. Feature-detect rather than user-agent sniff wherever a runtime check exists.
| Option | Type | Default | Honoured by | Notes |
|---|---|---|---|---|
body |
string |
'' |
all | Two lines before truncation on most platforms; front-load the meaning. |
icon |
string (URL) |
platform default | all | Ship 192×192 PNG; must be same-origin or CORS-readable HTTPS. |
badge |
string (URL) |
none | Chromium on Android | Monochrome 96×96 alpha mask for the status bar; ignored on desktop. |
image |
string (URL) |
none | Chromium desktop + Android | Wide hero, roughly 2:1. Firefox and Safari drop it silently. |
actions |
NotificationAction[] |
[] |
Chromium, Firefox 152+ | Only the first Notification.maxActions entries render; two in practice. |
tag |
string |
'' |
all | Same tag replaces the existing notification in place instead of stacking. |
renotify |
boolean |
false |
Chromium, Firefox | Requires a tag. Re-alerts sound/vibration on replacement. Throws if tag is absent. |
silent |
boolean |
false |
Chromium, Firefox | Suppresses sound and vibration. Mutually exclusive with vibrate. |
requireInteraction |
boolean |
false |
Chromium desktop | Card stays until dismissed. Ignored on Android and Safari. |
vibrate |
number[] |
none | Chromium on Android | Millisecond on/off pattern, e.g. [200, 100, 200]. Desktop ignores it. |
data |
any structured-cloneable | null |
all | Your private payload; read back as event.notification.data. |
timestamp |
number (epoch ms) |
show time | Chromium, Firefox | Backdate or forward-date the displayed time; does not schedule anything. |
dir |
'auto' | 'ltr' | 'rtl' |
'auto' |
all | Text direction for title and body. |
lang |
BCP 47 string |
'' |
all | Announced by screen readers; also hints at hyphenation. |
Three of these carry sharp edges. renotify: true without a tag throws a TypeError and rejects the showNotification() promise, which — if that promise was your only waitUntil() argument — turns into a silent push. silent: true combined with a vibrate array is a spec conflict; Chromium resolves it in favour of silence, Firefox has historically honoured the vibration. And timestamp is cosmetic only: it relabels the card, it does not delay delivery, so scheduling still belongs on your server.
data is the one field with no rendering behaviour at all, and it is the most important one. It is structured-cloned into the notification record, survives worker termination, and comes back verbatim in both notificationclick and notificationclose. Anything your click handler needs — the deep-link URL, a campaign id, a message id for read-receipts — belongs there. Remember it is counted inside the same encrypted record as the rest of your payload, so it competes with the body text for the 4 KB aes128gcm budget described in Push API Payload Encryption.
The mandatory visible notification rule
When you called pushManager.subscribe({ userVisibleOnly: true, … }) you signed a contract: every push message delivered to this subscription will result in a user-visible notification. Chromium and Firefox both refuse to create a subscription without that flag, so the contract is not optional.
The enforcement is behavioural rather than an exception. If your push handler finishes — including everything passed to waitUntil() — without the notification list for your origin having grown, the browser substitutes its own generic notification, typically worded “This site has been updated in the background”. Chromium tracks a per-origin budget of these substitutions. Burn through it and the browser stops waking your worker for pushes at all, or silently drops the permission. There is no console error and no API to read your remaining budget; the first symptom is a delivery-versus-display gap in your analytics.
The failure modes that produce it are mundane: a rejected showNotification() promise, a JSON.parse() throw on a malformed payload, an await on a fetch that never resolves, or a code path that decides the notification is not worth showing and returns early. All four are prevented by the same discipline — always end every branch with a showNotification() call, and wrap the whole handler so that even the error path shows something.
Handling notificationclick and notificationclose
notificationclick fires for two distinct gestures: a tap on the card body, and a tap on an action button. They arrive through the same handler and are distinguished by event.action, which is the empty string for a body tap and the action id string for a button. Dispatch on it explicitly; never assume a click means “open the app”.
notificationclose fires when the user dismisses the notification without activating it — swiping it away, clicking the system close control, or clearing the tray. It does not fire when the notification is replaced by a same-tag update, and it does not fire on notification.close() calls you make yourself. It is the right hook for dismissal analytics and nothing else: you cannot open a window from it, because there is no user activation attached.
Call event.notification.close() first thing in your click handler. On desktop Chromium the card lingers until you do, and users read that as an unresponsive app. Then read event.notification.data, decide a destination, and return the resulting promise through event.waitUntil().
event.action before you do anything else; the empty string is a real, meaningful value, not a missing one.Wiring a complete handler, step by step
- Parse defensively and always produce options. Never let a malformed payload reach
showNotification()asundefined. - Serialise everything the click needs into
data. URL, entity id, campaign id — nothing else survives the worker restart. - Return a single promise chain to
waitUntil(). Oneevent.waitUntil(handler())call, andhandler()must resolve only after the notification is showing. - Show a fallback notification in the
catch. A generic card is infinitely cheaper than a burnt permission budget. - Close the notification first in the click handler, then dispatch on
event.action, then navigate inside the samewaitUntil().
// sw.js — push side
self.addEventListener('push', (event) => {
const render = async () => {
let p = {};
try {
p = event.data ? event.data.json() : {};
} catch (err) {
p = {};
}
const options = {
body: (p.body || 'You have a new update.').slice(0, 140),
icon: p.icon || '/icons/notification-192.png',
badge: '/icons/badge-96.png',
image: p.image,
tag: p.tag || 'default',
renotify: Boolean(p.tag) && Boolean(p.renotify),
requireInteraction: Boolean(p.requireInteraction),
silent: false,
timestamp: p.sentAt || Date.now(),
dir: p.dir || 'auto',
lang: p.lang || 'en',
actions: (p.actions || []).slice(0, Notification.maxActions ?? 2),
// Everything the click handler will need, round-tripped verbatim.
data: {
url: p.url || '/',
messageId: p.messageId || null,
campaignId: p.campaignId || null
}
};
await self.registration.showNotification(p.title || 'New update', options);
};
// One promise, one waitUntil, a fallback that always shows something.
event.waitUntil(
render().catch(() =>
self.registration.showNotification('New update', {
body: 'Open the app to see what changed.',
icon: '/icons/notification-192.png',
tag: 'fallback'
})
)
);
});
The click side reads the same data object back out. Note that event.notification.data is already a structured clone — it is a live object, not a JSON string, so no parsing step is needed or wanted.
// sw.js — click and close side
self.addEventListener('notificationclick', (event) => {
const notification = event.notification;
const data = notification.data || {};
notification.close();
const routes = {
'': data.url || '/',
'view': data.url || '/',
'track': `${data.url || '/'}?src=push-track`
};
const run = async () => {
if (event.action === 'dismiss') {
await report('dismiss', data);
return;
}
const target = new URL(routes[event.action] || '/', self.location.origin);
const clientList = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true
});
for (const client of clientList) {
if (new URL(client.url).pathname === target.pathname && 'focus' in client) {
await report('click', data);
return client.focus();
}
}
await report('click', data);
return self.clients.openWindow(target.href);
};
event.waitUntil(run());
});
self.addEventListener('notificationclose', (event) => {
event.waitUntil(report('close', event.notification.data || {}));
});
function report(type, data) {
return fetch('/api/notification-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, ...data, at: Date.now() }),
keepalive: true
}).catch(() => undefined);
}
Two details in that handler matter more than they look. matchAll({ includeUncontrolled: true }) is required because a tab loaded before the current worker activated is not “controlled” by it and would otherwise be invisible — a subscription-scope subtlety covered in the deep-dive on why notificationclick opens the wrong window. And the analytics fetch() is inside the waitUntil() chain rather than fired and forgotten, because an un-awaited request issued microseconds before the worker is torn down is routinely dropped.
Verification
- Open DevTools → Application → Service Workers, tick Update on reload, and confirm your worker is
activated and is running. - Type a JSON payload into the Push field beside your worker registration and click Push. This dispatches a real
pushevent withevent.datapopulated, without touching your server. - Watch the Console for the fallback path firing — if you see the fallback card, your primary render threw.
- Click the notification body and each action button in turn, logging
event.actionat the top of the handler. Confirm you see'', then each action id. - Dismiss a notification with the system control and confirm
notificationclosefires exactly once. - In
chrome://serviceworker-internals, check the worker’s running status after the click to confirm yourwaitUntil()chain actually held it open.
A quick payload for step 2:
{
"title": "Order 4821 has shipped",
"body": "Arriving Thursday. Track it now.",
"url": "/orders/4821",
"messageId": "m_88213",
"tag": "order-4821",
"actions": [{ "action": "track", "title": "Track order" }]
}
Error and edge-case matrix
| Condition | Cause | Fix |
|---|---|---|
| Generic “site updated in the background” card | Handler ended without showNotification() |
Add the catch fallback; verify every branch shows something |
showNotification() promise rejects |
renotify: true with no tag |
Always set tag when using renotify |
| Notification shows, then the worker dies mid-fetch | waitUntil() not passed the fetch promise |
Chain analytics into the same promise you pass to waitUntil() |
event.notification.data is null on click |
data omitted, or a non-cloneable value was passed |
Pass plain JSON-shaped objects only; no functions, no DOM nodes |
| Action buttons missing | Platform renders fewer than you supplied, or engine lacks support | Slice to Notification.maxActions; keep the body tap sufficient |
| Second notification replaces the first unexpectedly | Shared tag across unrelated messages |
Namespace tags per entity, e.g. order-4821 |
| Click opens a duplicate tab | matchAll() omitted or includeUncontrolled false |
Use the matching loop above before openWindow() |
vibrate ignored |
Desktop, or silent: true also set |
Treat vibration as Android-only enhancement |
Cross-browser notes
Chromium is the reference implementation and the only engine that honours the full option set, including image, requireInteraction on desktop, action icons, and badge on Android. Firefox implements the core set and added actions in version 152; earlier releases ignore the array entirely without error. Safari on macOS and iOS maps web notifications onto the native banner: no image, no actions, no requireInteraction, and a shorter effective title. The engine-by-engine detail lives in the cross-browser notification quirks guide, and the version thresholds for buttons specifically are in which browsers support web push notification actions.
Because unsupported options are dropped silently, the safe construction order is: build a payload that reads correctly with only title, body and icon, then layer image, actions and requireInteraction on top as enhancements. If removing every enhancement leaves a notification that no longer makes sense, the payload is wrong for at least a third of your audience.
Related
- Adding action buttons to web push notifications — the
actionsarray end to end, frommaxActionsdetection to routing. - notificationclick not opening the right window — the
matchAll()/focus()/openWindow()decision tree and its scope traps. - Cross-Browser Notification Quirks — which engine drops which option, and how to normalise before dispatch.
- Push API Payload Encryption — the 4 KB
aes128gcmbudget yourdatafield competes for. - Service Worker Registration Patterns — scope and lifecycle rules that decide whether the handler runs at all.
Back to Core Protocols & Browser Implementation
FAQ
Why does Chrome show "This site has been updated in the background"?
Your push handler completed without a notification appearing for your origin. The subscription was created with userVisibleOnly: true, so Chromium substitutes its own generic card. The usual causes are a rejected showNotification() promise, a JSON.parse() throw, or an async call that was never passed to event.waitUntil(). Add a catch branch that shows a fallback notification.
What is the difference between icon, badge and image?
icon is the square app-level graphic on the card, typically 192×192. badge is a monochrome alpha mask, typically 96×96, shown in the Android status bar and ignored on desktop. image is a wide hero graphic rendered inside the expanded card, supported by Chromium on desktop and Android and dropped by Firefox and Safari.
Does event.action tell me if the user clicked the notification body?
Yes. event.action is the empty string for a body tap and the action id string for an action button. The empty string is a meaningful value, not a missing one, so route it explicitly rather than treating it as a falsy default.
Why is my analytics request from notificationclick never arriving?
The worker was torn down before the request flushed. Any fetch() you issue must be part of the promise you pass to event.waitUntil(); a fire-and-forget call made just before the handler returns is routinely dropped. Adding keepalive: true helps but is not a substitute for waitUntil().
Can I schedule a notification with the timestamp option?
No. timestamp only changes the time string the platform prints on the card — it can backdate or forward-date the label but it never delays display. Scheduling belongs on your server or in a queue; the notification renders the moment showNotification() runs.