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.

Service worker lifetime across the push event and the click event The upper lane runs from the push event through event.waitUntil to showNotification and then worker termination. Between the lanes the worker is idle. The lower lane restarts the worker on a user click and runs notificationclick under its own separate waitUntil budget. Two worker lifetimes, two separate waitUntil() budgets Lane 1 — push wake-up push event worker starts event.waitUntil(p) ~30 s ceiling showNotification() promise resolves worker terminated Module-scope variables die here. Only notification.data and IndexedDB survive. idle — no JavaScript running, notification sits in the OS tray Lane 2 — click wake-up user taps card or button worker restarts script re-evaluated notificationclick own waitUntil() focus() or openWindow() Navigation calls made after this budget closes are silently discarded. A promise you forget to pass to waitUntil() is a race you lose on slow devices first.
The push event and the click event are independent worker invocations; nothing but serialised data crosses the gap between them.

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.

Annotated notification card mapped to showNotification options A mock notification card on the left with six numbered markers on the icon, title, body, metadata row, hero image and action button row. A numbered legend on the right names the option that controls each region. Which option paints which region of the card ico Order 4821 has shipped Arriving Thursday. Track it now. shop.example.com · 2 min ago hero image slot Track order Not mine 1 2 3 4 5 6 1 icon — 192x192 square, plus badge off-card 2 title — the required first argument 3 body — two lines, then an expander 4 origin + timestamp, dir and lang apply here 5 image — Chromium desktop and Android only 6 actions — only the first two ever render tag, renotify, silent, requireInteraction and vibrate change behaviour, not layout. data never renders — it is your private round-trip channel to the click handler.
Six visible regions, one invisible payload: the options that draw the card versus the options that only change behaviour.

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.

Consequences of skipping showNotification on a userVisibleOnly subscription A push event reaches a decision: was showNotification called inside waitUntil. Yes leads to a normal render with permission untouched. No leads to the browser substituting a generic background-update notification, which escalates to push being muted for the origin. What a silent push actually costs push delivered userVisibleOnly showNotification() before waitUntil ends? yes Card renders normally budget untouched no Browser shows its own "updated in the background" Repeat offences push muted for the origin Common causes JSON.parse throws promise not awaited renotify without tag early return branch
There is no error event for a silent push — the cost is paid quietly, as a shrinking share of deliveries that ever reach a screen.

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 dispatch from card body and action buttons A notification card with a body region and two action buttons. Each click target maps to an event.action value: empty string for the body, reply for the first button, snooze for the second. Each value maps to a distinct handler outcome. One handler, three click targets, three code paths Message from Ada Are we still on for 15:00? body region — the default target Reply Snooze actions[0] and actions[1] event.action === '' event.action === 'reply' event.action === 'snooze' focus tab or openWindow open composer deep link POST, then re-show later notificationclose is a fourth path — dismissal, not activation. It carries the same notification.data, has no event.action, and cannot open a window. Use it for dismissal telemetry only, still wrapped in event.waitUntil().
Route on 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

  1. Parse defensively and always produce options. Never let a malformed payload reach showNotification() as undefined.
  2. Serialise everything the click needs into data. URL, entity id, campaign id — nothing else survives the worker restart.
  3. Return a single promise chain to waitUntil(). One event.waitUntil(handler()) call, and handler() must resolve only after the notification is showing.
  4. Show a fallback notification in the catch. A generic card is infinitely cheaper than a burnt permission budget.
  5. Close the notification first in the click handler, then dispatch on event.action, then navigate inside the same waitUntil().
// 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

  1. Open DevTools → Application → Service Workers, tick Update on reload, and confirm your worker is activated and is running.
  2. Type a JSON payload into the Push field beside your worker registration and click Push. This dispatches a real push event with event.data populated, without touching your server.
  3. Watch the Console for the fallback path firing — if you see the fallback card, your primary render threw.
  4. Click the notification body and each action button in turn, logging event.action at the top of the handler. Confirm you see '', then each action id.
  5. Dismiss a notification with the system control and confirm notificationclose fires exactly once.
  6. In chrome://serviceworker-internals, check the worker’s running status after the click to confirm your waitUntil() 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.

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.