Skip to content

Notifications API

The Notifications API lets a web app show system-level notifications: the toasts, banners and Notification Center entries that the operating system displays outside the browser window. It has two halves: Notification.requestPermission() asks the user once for the notifications permission, and ServiceWorkerRegistration.showNotification() displays a persistent notification that survives the page closing and reports clicks to your service worker. Every option you pass is a hint that each platform (Windows, macOS, Android, iOS, ChromeOS) renders differently or ignores. Push messages are the most common trigger, but a notification is a separate API with its own lifecycle, events and limits.

Key takeaways

  • Always show notifications with registration.showNotification(). new Notification() throws a TypeError in Chrome on Android and inside service workers, and does not exist in iOS Safari tabs. It also creates non-persistent notifications that are not tied to your service worker.
  • Permission has three states: default, granted and denied. Request it only from a user gesture, after explaining the value. Firefox and Safari enforce the gesture, and Chrome quiets or embargoes origins whose prompts get dismissed or ignored.
  • Option support varies widely: actions (max 2) work in Chromium and Firefox 152+ but not Safari. image, badge, renotify, timestamp and vibrate are Chromium-only, and Safari ignores icon and does not replace notifications by tag.
  • tag replaces an existing notification from the same origin. renotify: true re-alerts on replacement and throws if tag is empty. silent: true combined with vibrate throws.
  • Handle notificationclick in the service worker: call notification.close(), then focus an existing window or clients.openWindow(). Chromium allows one window interaction per click, within 10 seconds of waitUntil().
  • notificationclose fires only when the user dismisses a notification, never for close() or tag replacement.

How the Notifications API is structured

The WHATWG Notifications API Standard defines an abstract notification: a title, body, direction, language, tag, data, timestamp, optional image, icon and badge URLs, a vibration pattern, preferences (renotify, silent, requireInteraction), a list of actions, and, since Safari 18.4 shipped it, a navigation URL. The browser keeps a list of notifications and hands each one to the platform's notification system.

The standard distinguishes two kinds by whether a service worker registration is attached:

Non-persistent notification Persistent notification
Created with new Notification(title, options) registration.showNotification(title, options)
Service worker registration null The registration you called it on
Available in Window and dedicated workers (not service workers) Pages and service workers (via self.registration)
Events show, click, close, error on the Notification object notificationclick, notificationclose in the service worker
Survives page close No: the object and its listeners die with the page Yes: clicks start the service worker
actions Not allowed (TypeError) Allowed
Platform guidance in the spec "User agents should run the close steps … a couple of seconds after they have been created" and "should not display" them in the notification center "should persist" them and "should display" them in the notification center
Chrome on Android Constructor always throws Supported
iOS/iPadOS Not available Home Screen web apps, iOS 16.4+

For a PWA the choice is made for you: use persistent notifications. They work on every platform that supports web notifications at all, their clicks reach your code even after every tab has closed, and they integrate with push.

flowchart LR
    P[Page] -->|"requestPermission()"| Perm{{notifications permission}}
    P -->|"registration.showNotification()"| SW[Service worker registration]
    SW2[Service worker] -->|"self.registration.showNotification()"| SW
    SW --> List[(Browser list of notifications)]
    List --> OS[OS notification system]
    OS -->|user activates| Click[notificationclick in service worker]
    OS -->|user dismisses| Close[notificationclose in service worker]

The notifications permission

Notifications are a "powerful feature" identified by the name "notifications" in the Permissions specification. In Chromium, Firefox and Safari the push permission ("push" with userVisibleOnly: true) is tied to it: subscribing to push prompts for notifications if needed, and revoking notifications ends push delivery.

Notification.permission and the three states

Notification.permission is a static, synchronous getter that returns a NotificationPermission string. The spec maps the underlying permission state like this:

Notification.permission Permissions API state Meaning What you can do
"default" "prompt" The user has not decided (or the decision expired or was reset) Call requestPermission() from a user gesture
"granted" "granted" Allowed showNotification() and push subscriptions work
"denied" "denied" Blocked by the user, by policy, or automatically Nothing programmatic. Explain how to re-enable it in browser or OS settings

The getter is exposed in windows and workers, so a service worker can read Notification.permission before trying to show something. requestPermission() is [Exposed=Window] only: a service worker can never prompt.

The spec itself flags the synchronous getter as a design mistake ("Synchronous permissions are like synchronous IO, a bad idea") and recommends the Permissions API instead, which is also the only way to observe changes:

permission-status.js
// Observe permission changes, e.g. the user re-enabling notifications in site settings.
const status = await navigator.permissions.query({ name: "notifications" });
console.log(status.state); // "prompt" | "granted" | "denied"
status.addEventListener("change", () => {
  // Fires when the user changes the setting while the page is open.
  updateNotificationToggle(status.state);
});

PushManager.permissionState({ userVisibleOnly: true }) returns the same information from the push side, and is the check to use before subscribing.

Notification.requestPermission() signatures and requirements

notifications.webidl (excerpt)
[Exposed=Window] static Promise<NotificationPermission> requestPermission(
    optional NotificationPermissionCallback deprecatedCallback);

The method returns a promise that resolves to the new NotificationPermission value. It never rejects for "the user said no": a denial resolves with "denied", and a dismissed prompt usually resolves with "default". The optional callback argument is legacy. Safari before 15 only supported the callback form (MDN's compatibility data lists promise support from Chrome 47, Firefox 46 and Safari 15), so code that still has to run on very old Safari passes both:

request-permission-compat.js
function requestPermissionCompat() {
  return new Promise((resolve) => {
    // Old Safari ignores the return value and calls the callback; modern browsers do both.
    const maybePromise = Notification.requestPermission(resolve);
    if (maybePromise) maybePromise.then(resolve);
  });
}

Conditions under which the prompt is not shown, or the call is refused:

Condition Behavior
Insecure context (http: other than localhost) Chrome 62 and Firefox 67 require a secure context. Push and service workers require one everywhere, so treat HTTPS as mandatory.
Cross-origin iframe Chrome 62 removed requestPermission() from non-main frames. Firefox 70 blocks cross-origin iframes. Delegate to the top-level page.
No user activation Firefox 72 (Firefox for Android 79) and later only prompt in response to a user gesture such as click. Safari requires a gesture too. Chrome does not require one today, but see the quieting rules below.
Incognito / private browsing Chrome disables notifications in Incognito. Expect "denied" without a prompt.
iOS/iPadOS Safari tab Notification is undefined unless the page runs as a Home Screen web app (iOS 16.4+). Since iOS 26, any site added with Open as Web App qualifies, with no manifest display value required; earlier versions needed standalone or fullscreen.
Permission already granted or denied Resolves immediately with the current value. No prompt.
Origin under embargo (Chrome) Resolves without showing a prompt (see below).

How browsers throttle permission prompts

Notification prompts are among the most dismissed and most complained-about UI on the web, and browsers actively limit them.

Chrome's quieter permission UI. Chrome 80 introduced a quieter notification prompt: a crossed-out bell in the address bar on desktop and a mini-infobar on mobile instead of a modal bubble. According to the Chromium blog, users are enrolled automatically if they "repeatedly deny notifications across websites", and sites "with very low acceptance rates will be automatically enrolled in quieter prompts". From Chrome 84, sites with abusive permission requests or abusive notifications are automatically enrolled and the prompt warns that the site may be trying to trick the user. Chrome 86 extended this enforcement to abusive notification content (malware links, spoofed system messages).

Chrome's embargo. Chromium tracks how often each origin's prompt is dismissed (closed with the X) or ignored (left unanswered). After 3 dismissals or 4 ignores, Chromium places the origin under a 7-day embargo during which the prompt is not shown at all. With the quiet UI the thresholds drop to 1 dismissal or 2 ignores. Prompting on page load burns through that budget fast.

Automatic revocation. In October 2025 Google announced that Chrome on Android and desktop "will automatically remove notification permission for sites you haven't interacted with recently" when they send a high volume of notifications with low engagement. The post notes that "less than 1% of all notifications receive any interaction from users". It also says: "This feature does not revoke notifications for any installed web apps". Installation therefore protects your permission, but only if the app is actually installed.

Safari and Firefox skip the quieting heuristics and rely on the gesture requirement instead. Safari also revokes push permission from sites that receive pushes without showing notifications (see Push Notifications).

Design the ask, then ask

Tie the request to a feature the user just chose: "Notify me when my order ships", a bell icon on a conversation, a toggle in settings. Show your own in-page explanation first, call requestPermission() from the click on your button, and never on page load. A dismissed in-page explanation costs nothing, while a dismissed browser prompt counts toward the embargo.

A production permission flow

This module wraps the whole permission lifecycle for the page: feature detection, reading state, asking from a gesture, handling denied, and reacting to changes made in browser settings.

src/notification-permission.js
// Page-side notification permission management.
// Works in Chromium, Firefox and Safari (including iOS Home Screen web apps).

/** Is the Notifications API usable in this context at all? */
export function notificationsSupported() {
  return (
    window.isSecureContext &&
    "Notification" in window && // undefined in iOS Safari tabs and some embedded webviews
    "serviceWorker" in navigator &&
    "showNotification" in ServiceWorkerRegistration.prototype
  );
}

/** Current state as "unsupported" | "default" | "granted" | "denied". */
export function getNotificationPermission() {
  return notificationsSupported() ? Notification.permission : "unsupported";
}

/**
 * Ask for permission. MUST be called synchronously from a user gesture handler
 * (click / keydown), before any await, or Firefox and Safari refuse to prompt.
 * @returns {Promise<"granted"|"denied"|"default"|"unsupported">}
 */
export function requestNotificationPermission() {
  if (!notificationsSupported()) return Promise.resolve("unsupported");
  if (Notification.permission !== "default") {
    return Promise.resolve(Notification.permission);
  }
  // Call requestPermission() first, while the gesture is still active.
  return new Promise((resolve) => {
    const maybePromise = Notification.requestPermission(resolve); // legacy Safari callback
    if (maybePromise) maybePromise.then(resolve, () => resolve(Notification.permission));
  });
}

/** Keep UI in sync when the user changes the setting outside the page. */
export async function watchNotificationPermission(onChange) {
  if (!notificationsSupported() || !navigator.permissions?.query) return () => {};
  try {
    const status = await navigator.permissions.query({ name: "notifications" });
    const handler = () => onChange(status.state === "prompt" ? "default" : status.state);
    status.addEventListener("change", handler);
    return () => status.removeEventListener("change", handler);
  } catch {
    return () => {}; // "notifications" not queryable in this browser
  }
}
src/notification-toggle.js
import {
  getNotificationPermission,
  requestNotificationPermission,
  watchNotificationPermission,
} from "./notification-permission.js";

const toggle = document.querySelector("#notify-toggle");
const help = document.querySelector("#notify-help");

function render(state) {
  toggle.hidden = state === "unsupported";
  toggle.setAttribute("aria-pressed", String(state === "granted"));
  toggle.disabled = state === "denied";
  help.textContent =
    state === "denied"
      ? "Notifications are blocked. Enable them from the site settings next to the address bar, then reload."
      : state === "unsupported"
        ? "Add this app to your Home Screen to get notifications."
        : "";
}

toggle.addEventListener("click", async () => {
  // No await before this call: the user activation must still be valid.
  const state = await requestNotificationPermission();
  render(state);
  if (state === "granted") {
    // Subscribe to push here (see push-notifications.md), then confirm with a test notification.
    const registration = await navigator.serviceWorker.ready;
    await registration.showNotification("Notifications are on", {
      body: "You'll hear from us when your order ships.",
      tag: "notifications-enabled", // re-enabling replaces rather than stacks
    });
  }
});

render(getNotificationPermission());
watchNotificationPermission(render);

The unsupported branch mentions the Home Screen because in iOS and iPadOS the API only exists in installed web apps. See Web Push on iOS & Safari for the install-first flow.

Showing notifications: showNotification() vs new Notification()

The Notification constructor

new Notification(title, options) creates a non-persistent notification. The standard's constructor steps:

  1. If the global object is a ServiceWorkerGlobalScope, throw a TypeError.
  2. If options.actions is not empty, throw a TypeError ("Actions are only supported for persistent notifications").
  3. Create the notification (which can itself throw, see below).
  4. In parallel: if permission is not "granted", fire error on the object and stop. Otherwise run the show steps and then fire show.

The object gets onclick, onshow, onerror and onclose handlers. The standard makes the click event cancelable: if you do not call preventDefault(), the browser should focus the notification's related window. Chromium's exact errors show where the constructor fails:

Context Result in Chromium
Chrome on Android (any page) TypeError: Illegal constructor. Use ServiceWorkerRegistration.showNotification() instead.
Inside a service worker TypeError: Illegal constructor.
actions passed TypeError: Actions are only supported for persistent notifications shown using ServiceWorkerRegistration.showNotification().
iOS Safari Notification is not defined outside Home Screen web apps, so a reference throws ReferenceError

Why Android (and PWAs in general) need a service worker

Chromium disables the constructor on Android through a runtime feature flag, with the source comment: "Android won't be able to reliably support non-persistent notifications". A non-persistent notification is tied to a JavaScript object in a live page, while an Android notification sits in the system tray for hours, long after the OS has killed the tab's renderer process. When the user taps it there is no page to deliver click to. A persistent notification solves this: the browser stores it with the service worker registration, and a tap simply starts the service worker and fires notificationclick. The same reasoning applies on desktop, where a user may click a notification after closing the tab. Persistent notifications are the model the platform is built around.

registration.showNotification()

notifications.webidl (excerpt)
partial interface ServiceWorkerRegistration {
  Promise<undefined> showNotification(DOMString title, optional NotificationOptions options = {});
  Promise<sequence<Notification>> getNotifications(optional GetNotificationOptions filter = {});
};

The standard's steps, which explain every rejection you will see:

  1. If the registration has no active worker, reject with TypeError. Calling it from a page before the service worker has activated fails, so use navigator.serviceWorker.ready.
  2. Create the notification. Validation errors (silent + vibrate, renotify without tag, non-serializable data) reject the promise with that exception.
  3. In parallel: if permission is not "granted", reject with TypeError.
  4. Run the show steps: fetch the image, icon, badge and action icons, wait for the fetches to complete, replace any existing notification with the same tag and origin, display, and possibly alert.
  5. Resolve with undefined.

The promise therefore resolves only after the images have been fetched and the notification handed to the OS. Inside a push handler that matters: pass the showNotification() promise to event.waitUntil(), or the worker can be terminated while the icon is still downloading.

src/show-local-notification.js
// Show a notification from a page (e.g. a timer finished) using the persistent API.
export async function showLocalNotification(title, options = {}) {
  if (Notification.permission !== "granted") return false;
  const registration = await navigator.serviceWorker.ready; // guarantees an active worker
  try {
    await registration.showNotification(title, {
      // Explicit timestamp: displayed ordering follows when the event happened.
      timestamp: Date.now(),
      ...options,
    });
    return true;
  } catch (error) {
    // TypeError: permission revoked between the check and the call, or invalid options.
    console.warn("showNotification failed", error);
    return false;
  }
}

Suppress notifications while the user is looking at the app

Chrome skips its "you must show a notification" enforcement when the push arrives while a tab of the origin is visible: Chromium's push code treats sites "with a currently visible tab" as not needing a notification. In that case you can update the UI with postMessage instead of showing a redundant system notification. Firefox counts a push without a visible notification against the subscription's quota, and Safari expects a notification for every push, so on those engines show one anyway (a short, silent one is acceptable).

NotificationOptions reference

The full dictionary from the standard, with defaults:

NotificationOptions
dictionary NotificationOptions {
  NotificationDirection dir = "auto";      // "auto" | "ltr" | "rtl"
  DOMString lang = "";
  DOMString body = "";
  USVString navigate;                      // navigation URL (Safari 18.4+)
  DOMString tag = "";
  USVString image;
  USVString icon;
  USVString badge;
  VibratePattern vibrate;
  EpochTimeStamp timestamp;                // defaults to "now"
  boolean renotify = false;
  boolean? silent = null;                  // null = follow platform conventions
  boolean requireInteraction = false;
  any data = null;
  sequence<NotificationAction> actions = [];
};

dictionary NotificationAction {
  required DOMString action;
  required DOMString title;
  USVString navigate;
  USVString icon;
};
Option Type / default What it does Chromium Firefox Safari
body string, "" Secondary text ✅ ✅ ✅
dir "auto" Base direction for title, body and action titles ✅ ✅ ✅
lang string, "" BCP 47 language of the text ✅ ✅ ⚠️ macOS (Safari 11+), ❌ iOS
tag string, "" Identity for replacement ✅ ✅ ❌ accepted, no effect
icon URL Image shown in the notification (sender avatar, product image) ✅ ✅ ❌ uses the site or app icon
badge URL Small monochrome icon for space-constrained UI ✅ (used on Android) ❌ ❌
image URL Large content image ✅ (not on macOS) ❌ ❌
data any, null Structured-cloneable payload returned on click ✅ ✅ ✅
timestamp ms since epoch, now Time the event happened ✅ ❌ ❌
renotify boolean, false Re-alert when replacing a tagged notification ✅ ❌ ❌
silent boolean or null Suppress sound and vibration ✅ ✅ 132 ✅ macOS 16.6; ❌ iOS per MDN (see iOS and iPadOS)
requireInteraction boolean, false Keep the notification on screen until acted on ✅ (platform dependent) ⚠️ Windows only (117) ❌
vibrate number or number[] Vibration pattern ⚠️ ignored on Android 8+ ❌ ❌
actions array, [] Buttons (persistent only) ✅ max 2 ✅ 152, max 2 ❌
navigate URL URL to open on activation without running the service worker ❌ 🧪 ✅ 18.4

Support data as of September 2026, from MDN browser compatibility data and engine sources. Safari columns cover macOS 13+ and iOS/iPadOS 16.4+ Home Screen web apps unless noted.

Unknown options are ignored, not rejected. That is what lets you pass image everywhere and let each platform decide, but it also means a typo such as requireInteration fails silently.

Text: title, body, dir and lang

The title is the only required argument. It is plain text, not HTML. The standard asks user agents to apply the Unicode bidirectional algorithm to the title, body and each action title, treating line feeds as paragraph breaks, with dir overriding the base direction when it isn't "auto". dir also decides the visual order of side-by-side action buttons. lang is a BCP 47 tag. The standard does not validate it. Platforms that honor it can use it to pick fonts and to choose the speech voice when a screen reader announces the notification.

Practical limits come from the OS, not the API. Android truncates the collapsed body to one line (Chromium's own source says the body "Will be trimmed to one line of text by the Android notification system"). Windows toasts show a title plus up to four lines. macOS banners truncate both. Put the essential words first: "Alice: Are we still on for 3pm?" beats "New message received in your inbox from Alice".

Images: icon, badge and image

The show steps fetch every image URL (resolved against the calling context's base URL) before displaying, and wait for all of them. Failed or undecodable images are skipped silently, and the notification is shown without them. Chromium downscales oversized images to fixed maximums defined in notification_constants.h:

Resource Chromium maximum (px) Density comment in source Recommendation
icon 320 × 320 80 dip × 4 192 × 192 PNG or WebP, square, meaningful without text
badge 96 × 96 24 dip × 4 96 × 96 PNG, monochrome glyph on transparent background
image 1800 × 900 450 × 225 dip × 4 2:1 aspect ratio, key content centered (platforms crop)
Action icon 128 × 128 32 dip × 4 128 × 128 monochrome glyph

Serve these images from your origin and precache the common ones. The fetch happens while your service worker is handling a push event, possibly on a poor connection, and the notification waits for it. A slow CDN directly delays the notification.

Monochrome badge icons

The badge is what Android puts in the status bar at the top of the screen, and inside the notification header. Android renders status bar icons as a silhouette: it uses only the alpha channel and tints the shape with a system color. A full-color logo on an opaque background therefore turns into a solid white square. Rules for a badge that survives this:

  • Transparent background, single flat glyph, no gradients or thin hairlines.
  • Pixels are either opaque (part of the shape) or fully transparent, since partial alpha becomes partial tint.
  • Keep it recognizable at 24 dp (the icon is drawn at about 24 × 24 density-independent pixels).
  • Produce it at 96 × 96. Chromium will not use more.

Without a badge, Chrome shows its own icon in the status bar, which makes your notification look like it came from the browser. That is reason enough to always ship one.

tag and renotify: replacing notifications

A non-empty tag gives a notification an identity scoped to its origin. The show steps look for an existing notification in the list "whose tag is not the empty string and is notification's tag, and whose origin is same origin", and if the platform supports replacement, swap the new notification in place of the old one. Consequences:

  • One conversation, one notification. Tag chat notifications with the conversation ID and each new message replaces the previous one instead of stacking ten entries.
  • Replacement is quiet by default. A replaced notification does not play a sound, vibrate or pop up a banner again on most platforms. Set renotify: true to alert again. The standard runs the alert steps when renotify is true and the notification was either newly shown or replaced an old one.
  • renotify requires a tag. renotify: true with an empty tag throws TypeError: Notifications which set the renotify flag must specify a non-empty tag. in Chromium, and the standard requires the throw.
  • Replacement does not fire notificationclose for the old notification. Close events fire only for user dismissals.
  • Tags are per origin, not per registration. Two service workers on the same origin share tags.
  • Safari does not replace. WebKit accepts tag but does not replace existing notifications (WebKit bug 258922, "Push notifications with same tag do not replace each other", still open in 2026). On Safari, close the old notification yourself with getNotifications({ tag }) before showing the new one (code below). Even that is imperfect: WebKit bug 321668 (filed August 2026 against Safari 26.5 on macOS) reports that close() removes a persistent notification from getNotifications() while the native notification stays in Notification Center.

requireInteraction: keeping notifications on screen

requireInteraction: true asks that, "on devices with a sufficiently large screen, the notification should remain readily available until the end user activates or dismisses" it. Platform behavior:

Platform Default (false) requireInteraction: true
ChromeOS Chrome's message center pops the notification up and auto-minimizes it after about 20 seconds (per the Chrome 47 launch entry) Stays on screen
Windows (Chrome, Edge) Toast times out into Notification Center Chromium builds the toast with scenario="reminder", which stays until dismissed. Windows ignores the reminder scenario on toasts without buttons, so Chromium adds a "Close" button when you provide no actions.
macOS (Chrome) Banner or alert, per the user's macOS setting for the app Chrome 152 (August 2026) release notes, announcing native attribution for installed PWAs, state that "Chrome no longer supports the requireInteraction field for notifications on macOS". Persistence is the user's per-app Banners/Alerts choice, matching WebKit. Do not rely on it on macOS.
Android No effect: notifications stay in the shade until dismissed No effect
Firefox — Honored on Windows since Firefox 117. Behind a preference elsewhere.
Safari — Not supported. Users choose Banners or Alerts in System Settings.

Reserve it for things that need action (an incoming call, a two-factor approval, an expiring hold). Every sticky notification is an interruption, see Accessibility.

silent and vibrate

silent: true asks for no sound and no vibration. In the current standard silent is nullable: null (the default) means "follow platform conventions", while false asks for alerting behavior. Chromium still implements it as a plain boolean defaulting to false. Combining silent: true with vibrate throws: TypeError: Silent notifications must not specify vibration patterns.

vibrate takes a Vibration API pattern: a single duration or an array alternating vibrate and pause durations in milliseconds. Chromium sanitizes it to at most 99 entries and 10,000 ms per entry. In practice, vibrate does nothing on Android 8.0 and later. Android O moved sound and vibration into notification channels, configured once per channel. MDN's compatibility data records that "In Android Oreo and above, regardless of Chrome version, this parameter has no effect". No desktop platform vibrates. Treat vibrate as legacy.

timestamp

timestamp is an EpochTimeStamp (milliseconds since the Unix epoch) that records when the event happened, which may differ from when the notification is shown. The standard's example is a message that "couldn't immediately be delivered because the device was offline". If you omit it, it defaults to the current time. Chromium passes it to the platform: Android shows it as the notification time, Windows as the toast's displayTimestamp, and ChromeOS as relative time. Send the server-side event time in your push payload and pass it through, so a notification delivered after a flight shows "3 hours ago" and sorts correctly.

data

data is serialized with StructuredSerializeForStorage when the notification is created, and deserialized into event.notification.data in notificationclick, possibly after a browser restart, because Chromium stores notification data in a database with the registration. It accepts anything structured-cloneable: objects, arrays, Date, Map, typed arrays. Functions, symbols and DOM nodes throw a DataCloneError, and class instances come back as plain objects without their prototype or private fields. Keep it small and flat: the URL to open, the entity IDs, a message ID for de-duplication, analytics attributes. Never put tokens or secrets in it, because getNotifications() returns it to any script on your origin.

actions and Notification.maxActions

Actions are buttons attached to a persistent notification. Each NotificationAction has:

Member Required Meaning
action Yes An identifier you receive as event.action in notificationclick
title Yes Button label shown to the user
icon No Small icon for the button (Chromium fetches it; many platforms do not display it)
navigate No Per-action navigation URL (standard; Safari's declarative flow)

Notification.maxActions returns the number of actions the platform will display. The standard defines it as implementation-defined, and browsers "skip any excess entries" silently. Chromium returns 2 (kNotificationMaxActions = 2) on every platform. Firefox 152 (June 2026) added actions with the same limit of 2. Safari does not support actions at all, and Notification.maxActions is undefined there.

sw-actions.js
// Feature-detect action support inside the service worker or page.
const maxActions = "maxActions" in Notification ? Notification.maxActions : 0;

const actions = [
  { action: "reply", title: "Reply", icon: "/icons/actions/reply.png" },
  { action: "mark-read", title: "Mark as read", icon: "/icons/actions/check.png" },
  { action: "mute", title: "Mute thread" }, // dropped where maxActions is 2
].slice(0, maxActions);

Every action must also be available when the user clicks the notification body, because platforms that don't show actions deliver a click with event.action === "". On Chrome for Android the browser also appends its own buttons to web notifications (Chromium's source refers to "UA buttons such as 'Unsubscribe' and 'Site Settings'"). You cannot remove those.

Chromium's non-standard inline reply actions

Chromium implements an extension that is not in the standard: a NotificationAction can have type: "text" and a placeholder, which renders a text field in the notification on platforms that support inline replies. The typed text arrives as event.reply in notificationclick. Passing placeholder on a type: "button" action throws TypeError: Notifications of type "button" cannot specify a placeholder. No other engine implements this. If you use it, treat event.reply as optional and open a reply UI when it is null or undefined.

The standard now includes a navigate member (and per-action navigate), shipped in Safari 18.4 as part of Declarative Web Push. When a notification or action with a navigation URL is activated, the standard's activation steps navigate an existing window or open a new one to that URL and return without firing notificationclick. The spec says this intentionally lets an action without a navigation URL fall through to the click event. If you set navigate, do not also expect to route that click in your service worker on engines that implement it. Engines that don't implement it ignore the option and fire notificationclick as usual, so a portable handler reads the URL from data and treats navigate as an optimization. See Web Push on iOS & Safari for the declarative payload format.

Handling clicks and closes in the service worker

notificationclick: what the event gives you

When the user activates a persistent notification (or one of its actions), the browser starts your service worker if needed and dispatches a NotificationEvent:

Property Value
event.notification A new Notification object representing the clicked notification: title, body, tag, data, actions, etc.
event.action The action string of the clicked button, or "" for a click on the notification body
event.reply Chromium-only: text from an inline reply action
event.waitUntil() Extends the event. Required for any async work.

The notification is not closed automatically on every platform. Call event.notification.close() first thing, or it can stay in the notification center after the user has handled it.

Opening and focusing windows

clients.openWindow() and WindowClient.focus() require the service worker to have window interaction permission, which it gets only while handling a user activation such as a notification click. Chromium's implementation is precise: during notificationclick the worker may "allow one window to be focused or opened". When you call waitUntil(), that allowance lasts 10 seconds (kWindowInteractionTimeout = 10). If you do a slow fetch() before calling openWindow(), the call rejects with InvalidAccessError and nothing opens. Open or focus the window first, then do the slow work.

sw.js (notificationclick)
// Open or focus the app on notification click, route actions, then record the interaction.
const APP_ORIGIN = self.location.origin;

/** Only ever navigate to same-origin URLs taken from notification data. */
function safeUrl(raw, fallback = "/") {
  try {
    const url = new URL(raw ?? fallback, APP_ORIGIN);
    return url.origin === APP_ORIGIN ? url.href : new URL(fallback, APP_ORIGIN).href;
  } catch {
    return new URL(fallback, APP_ORIGIN).href;
  }
}

async function focusOrOpen(targetUrl) {
  const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
  // 1. A window already showing the target URL: focus it.
  const exact = windows.find((c) => c.url === targetUrl);
  if (exact) return exact.focus();
  // 2. Any window of the app: navigate it (must be controlled for navigate()) and focus.
  const any = windows.find((c) => new URL(c.url).origin === APP_ORIGIN);
  if (any) {
    const focused = await any.focus(); // uses the one window interaction
    try {
      return (await focused.navigate(targetUrl)) ?? focused;
    } catch {
      // navigate() rejects for uncontrolled clients; tell the page to route itself instead.
      focused.postMessage({ type: "NAVIGATE", url: targetUrl });
      return focused;
    }
  }
  // 3. No window: open one. Opens the installed PWA window when the URL is in scope.
  return self.clients.openWindow(targetUrl);
}

self.addEventListener("notificationclick", (event) => {
  const notification = event.notification;
  const data = notification.data ?? {};
  notification.close(); // not automatic on every platform

  const work = (async () => {
    switch (event.action) {
      case "mark-read":
        // No window needed: act in the background and stop.
        await fetch(`/api/messages/${encodeURIComponent(data.messageId)}/read`, {
          method: "POST",
          credentials: "include",
        });
        return;
      case "reply":
        if (typeof event.reply === "string" && event.reply.trim()) {
          // Chromium inline reply: send without opening a window.
          await fetch(`/api/threads/${encodeURIComponent(data.threadId)}/messages`, {
            method: "POST",
            credentials: "include",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text: event.reply }),
          });
          return;
        }
        await focusOrOpen(safeUrl(`/threads/${data.threadId}?compose=1`));
        break;
      default:
        // Body click (event.action === "") or an action without special handling.
        await focusOrOpen(safeUrl(data.url));
    }
    // Slow, non-critical work after the window interaction has been used.
    await fetch("/api/analytics/notification-click", {
      method: "POST",
      credentials: "include",
      keepalive: true,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ tag: notification.tag, action: event.action, id: data.messageId }),
    }).catch(() => {});
  })();

  event.waitUntil(work);
});

clients.openWindow() resolves with a WindowClient for same-origin URLs and null for cross-origin ones. When the URL is within an installed PWA's scope, Chromium and Safari open it in the app window rather than a browser tab. Messaging details are in Messaging & the Clients API.

notificationclose: user dismissals only

notificationclose fires in the service worker when "notification was closed by the end user": swiped away, the X clicked, cleared from the notification center. It does not fire when you call notification.close(), when a tagged notification is replaced, or when the platform expires the notification. Some platforms and browsers do not report dismissals reliably (for example "Clear all" in some notification centers), and WebKit bug 303532 reports that on macOS Safari 18.6 a click on a push notification fires both notificationclick and notificationclose. Treat it as an analytics signal, never as state you depend on, and de-duplicate a close that arrives right after a click for the same notification.

sw.js (notificationclose)
self.addEventListener("notificationclose", (event) => {
  const { messageId, campaign } = event.notification.data ?? {};
  // Dismissal is useful feedback: too many dismissals means too many notifications.
  event.waitUntil(
    fetch("/api/analytics/notification-dismiss", {
      method: "POST",
      credentials: "include",
      keepalive: true,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messageId, campaign, tag: event.notification.tag }),
    }).catch(() => {}),
  );
});

Events on non-persistent notifications

If you do use the constructor on desktop (for example in a web app that runs only in desktop Chromium or Firefox), its events fire on the object in the page:

Event When
show After the show steps complete
click User activated it. Cancelable: without preventDefault() the browser should focus the page.
close Closed by the user, the platform, close(), or replacement by tag
error Permission not granted, or showing failed

These events are lost if the page is closed or discarded, which is exactly why the persistent model exists.

Managing notifications with getNotifications()

registration.getNotifications({ tag }) resolves with the persistent notifications that are currently shown for this registration and this origin, optionally filtered by exact tag, "in creation order". Each call returns new Notification objects, so compare by tag or by data, never by identity. On every browser it is the only way to inspect what the user currently sees.

Grouping: collapsing many messages into one summary

The web has no explicit notification-group API. The portable pattern combines tag replacement with getNotifications(): read the notification currently shown for a thread, merge the new message into its data, and replace it with a summary.

sw.js (push with grouping)
self.addEventListener("push", (event) => {
  const msg = event.data?.json() ?? {};
  // msg: { id, threadId, sender, text, sentAt, url }
  event.waitUntil(showGroupedMessage(msg));
});

async function showGroupedMessage(msg) {
  const tag = `thread-${msg.threadId}`;
  const [existing] = await self.registration.getNotifications({ tag });
  const previous = existing?.data?.messages ?? [];

  // De-duplicate retries of the same push (the sender may deliver twice).
  if (previous.some((m) => m.id === msg.id)) return;

  const messages = [...previous, { id: msg.id, sender: msg.sender, text: msg.text }].slice(-5);
  const count = (existing?.data?.count ?? 0) + 1;

  const title = count === 1 ? msg.sender : `${count} new messages`;
  const body =
    count === 1
      ? msg.text
      : messages.map((m) => `${m.sender}: ${m.text}`).join("\n"); // newest last

  // Safari does not replace by tag, so close the old one explicitly. Elsewhere this turns an
  // in-place update into close-then-show, which is why renotify is set below anyway.
  existing?.close();

  await self.registration.showNotification(title, {
    body,
    tag,
    renotify: true, // alert again for each new message in the thread
    icon: "/icons/notification-192.png",
    badge: "/icons/badge-96.png",
    timestamp: msg.sentAt ?? Date.now(),
    lang: "en-US",
    data: { url: msg.url, threadId: msg.threadId, messageId: msg.id, count, messages },
    actions: [
      { action: "reply", title: "Reply" },
      { action: "mark-read", title: "Mark as read" },
    ],
  });
}

Closing notifications the user has already seen elsewhere

When the user reads a thread in the app (on this device, or on another device and you learn about it by push), clear the stale notification. From a page, the registration is reachable directly:

src/clear-thread-notifications.js
// Call when the user opens a thread in the app.
export async function clearThreadNotifications(threadId) {
  if (!("serviceWorker" in navigator)) return;
  const registration = await navigator.serviceWorker.ready;
  const notifications = await registration.getNotifications({ tag: `thread-${threadId}` });
  notifications.forEach((n) => n.close()); // close() does not fire notificationclose
}
sw.js (read-receipt push)
// Server sends { type: "thread-read", threadId } when another device reads the thread.
// Chrome and Firefox still expect a visible notification for this push unless a tab is
// visible; Safari always does. Prefer folding read state into the next real notification.
async function handleThreadRead({ threadId }) {
  const notifications = await self.registration.getNotifications({ tag: `thread-${threadId}` });
  notifications.forEach((n) => n.close());
}

Closing a notification does not satisfy the visible-notification rule

A push whose only effect is closing a notification leaves nothing visible, and Chrome, Firefox and Safari count it as a silent push. Chromium's source even has a TODO noting that hiding a notification should count as a user-visible action, and it does not today. Clear stale notifications opportunistically when you show the next real one, or when the app opens.

Platform differences

Every browser hands persistent notifications to the operating system's notification service where one exists. The OS then decides layout, truncation, grouping, timeouts, Do Not Disturb / Focus behavior and which options are shown. Test on each platform you support.

Windows

Chrome and Edge render web notifications as native Windows toasts that land in Notification Center. Chromium generates the toast XML itself (notification_template_builder.cc), which tells you exactly how options map:

  • icon → toast image with placement="appLogoOverride" (the square image at the left).
  • image → placement="hero", a wide banner at the top of the toast.
  • timestamp → the toast's displayTimestamp.
  • silent: true → <audio silent="true"/>.
  • requireInteraction: true → scenario="reminder", with a "Close" button injected when you supplied no actions, because "the Action Center does not respect the Reminder setting on notifications with no buttons".
  • Actions → toast buttons. Chromium adds a context-menu entry for site notification settings.
  • The source attribution line shows the site's origin.

Windows Do not disturb and Focus sessions suppress banners but still collect notifications in Notification Center. Firefox on Windows also uses the native system and supports requireInteraction there. Chromium has an experimental scenario: "incoming-call" option, restricted to installed web apps and not enabled by default, that produces call-styled toasts with colored buttons and a ringtone. Do not rely on it in production.

macOS

Chrome has used the macOS notification system since Chrome 59, Safari since its first implementation. The user chooses per app whether notifications appear as temporary Banners or persistent Alerts, and whether they show previews, play sounds or appear on the lock screen.

  • Attribution. Until Chrome 152, every website's notifications appeared under "Google Chrome". Since Chrome 152 (stable August 2026), notifications from installed PWAs are attributed to the PWA's own app shim, with its name and icon, and get their own entry in System Settings → Notifications. The same release notes say that, to align with native macOS apps, "Chrome no longer supports the requireInteraction field for notifications on macOS", and that the Badging API now requires notification permission for the badge to appear. Notifications from non-installed websites still appear under Google Chrome.
  • No content image. Chromium disables the image option on macOS (the runtime feature is off on Mac because "The Notification Center on Mac OS X does not support content images"), so 'image' in Notification.prototype is false there.
  • Safari (macOS 13 Ventura and later for service-worker notifications) shows the site's icon rather than your icon, and supports silent from Safari 16.6, but has no actions, no requireInteraction and no tag replacement.
  • Focus modes filter notifications per app, so a user may deliberately silence your PWA.

Android

  • Service worker only. The constructor throws, as covered above.
  • One channel per site. Chrome creates an Android notification channel per origin (SiteChannelsManager), with the origin as the channel's visible name. Users can change importance, sound and vibration for your site in Android's settings, and those settings win over anything you pass. That is also why vibrate has no effect on Android 8+.
  • Installed PWAs (WebAPKs) show notifications under the app's own identity rather than Chrome's.
  • The badge is the status bar icon. Always provide a monochrome badge.
  • image appears when the user expands the notification (big-picture style).
  • The body collapses to one line, and the expanded view shows more.
  • Chrome adds its own buttons ("Unsubscribe", "Site settings") next to your actions.
  • App-level permission. On Android 13 and later, the browser itself needs the Android POST_NOTIFICATIONS runtime permission. If the user denied it to Chrome, granting your site permission is not enough to see anything.
  • Abusive content detection. Chrome for Android has been experimenting with an on-device model that hides the contents of notifications suspected to be abusive and offers to unsubscribe (behind a flag in Chrome 134). Spammy notification copy has consequences beyond annoyed users.

iOS and iPadOS

  • Notifications and push work only for Home Screen web apps (iOS/iPadOS 16.4+). Since iOS 26, any site added with Open as Web App (on by default) is a web app, and eligibility needs no manifest display value. On iOS 16.4 to 18, the manifest display had to be standalone or fullscreen. In a Safari tab, Notification is undefined.
  • Permission must be requested from a user gesture inside the installed app.
  • Each web app is its own app in Settings → Notifications, and appears on the Lock Screen, in Notification Center, on a paired Apple Watch, and within Focus filters.
  • Supported: title, body, dir and data, plus navigate from iOS 18.4. Not supported or without effect: tag replacement, lang, icon (the app icon is used), image, badge, actions, requireInteraction, vibrate, renotify and timestamp.
  • silent is not supported on iOS according to MDN. WebKit's source suggests the default sound is played unless silent is true, so test on a device before relying on either behavior.
  • Click handling is the weak spot. MDN's compatibility data does not list notificationclick as supported on iOS, and WebKit's tracker has open reports of it misbehaving in Home Screen apps: bug 268797 ("notificationclick events in serviceworkers not firing", with reports up to iOS 26.6), bug 282935 ("notificationclick event not triggered if the progressive web app(pwa) is not opened") and bug 263687 (openWindow()/navigate() landing on the app's root URL, which a later commenter could not reproduce on iOS 26.0). Make your app's start URL useful on its own, for example by opening an inbox or activity view that shows what the notification was about, so a click that loses its deep link still lands somewhere sensible. navigate (Declarative Web Push) sidesteps the service worker entirely on iOS 18.4+.
  • Badges come from the Badging API, not from notification options.

The full picture, including Declarative Web Push and the EU distribution changes, is on Web Push on iOS & Safari and iOS & iPadOS.

ChromeOS and Linux

On ChromeOS, the system message center renders web notifications with the full option set, including image and action buttons, and applies the roughly 20-second auto-minimize described above unless requireInteraction is set. On Linux, Chrome hands notifications to the desktop's native notification server over the freedesktop.org org.freedesktop.Notifications D-Bus interface, so rendering depends on the desktop environment (GNOME, KDE and others differ in how they show images and actions).

Feature detection

Because unsupported options are silently ignored, detect support before you rely on them. The Notification.prototype attribute getters reflect what the engine implements:

src/notification-features.js
// Works in pages and in service workers (Notification exists in both).
export function notificationFeatures() {
  const has = (name) => typeof Notification !== "undefined" && name in Notification.prototype;
  return {
    supported: typeof Notification !== "undefined",
    persistent:
      typeof ServiceWorkerRegistration !== "undefined" &&
      "showNotification" in ServiceWorkerRegistration.prototype,
    actions: has("actions") && (Notification.maxActions ?? 0) > 0,
    maxActions: typeof Notification !== "undefined" ? (Notification.maxActions ?? 0) : 0,
    image: has("image"), // false on macOS Chromium, Firefox, Safari
    badge: has("badge"),
    renotify: has("renotify"),
    requireInteraction: has("requireInteraction"),
    silent: has("silent"),
    timestamp: has("timestamp"),
    vibrate: has("vibrate"),
    navigate: has("navigate"), // Safari 18.4+
  };
}

An attribute being present means the engine parses the option, not that the OS displays it. Firefox, for example, exposes requireInteraction but only honors it on Windows, and Safari exposes tag without replacement. Keep a short per-platform override list for the behaviors you depend on.

Accessibility

A notification is rendered by the operating system, so it inherits the OS's accessibility support: screen readers announce banners (usually title, then body), notification centers are keyboard- and switch-navigable, and system text size applies. Your job is to give the OS content that works in that setting, and to control how often you interrupt.

  • Make the text self-sufficient. The standard warns developers not to convey information through an image, icon, badge or vibration pattern "that is not otherwise accessible to the end user, especially since notification platforms that do not support these features might ignore them". A photo in image needs its meaning repeated in body.
  • Front-load meaning. Screen reader users hear the title first and often skip the rest. Prefer "Payment failed: card ending 4242" to "Important update about your account".
  • Set lang and dir when the notification's language differs from the user's system language, so platforms that honor them can pick the right voice, pronunciation and text direction.
  • Label actions with verbs that make sense out of context ("Mark as read", not "OK"), and make every action reachable in the app, because many platforms hide or collapse action buttons.
  • Avoid emoji-only or symbol-heavy titles. Screen readers read out emoji names ("red circle, red circle, flash sale"), which is noise.
  • Respect interruption controls. WCAG 2.2 success criterion 2.2.4 Interruptions (Level AAA) asks that "interruptions can be postponed or suppressed by the user, except interruptions involving an emergency". Offer per-category notification settings, quiet hours and a digest option in your app, and use requireInteraction only for things that truly need an answer.
  • Don't use notifications for in-app feedback. When the app is in the foreground, show an in-page status message with an ARIA live region (role="status" or role="alert") instead of a system notification, which screen readers may announce out of context.
  • Keep a history. Notifications disappear. An in-app activity feed gives users (including those who dismissed an announcement before hearing it) a place to review what they missed.

More patterns are on Accessibility.

Browser support

Feature Chrome / Edge (desktop) Chrome (Android) Firefox Safari (macOS) Safari (iOS/iPadOS)
Notification constructor ✅ 20 ❌ throws ✅ 22 ✅ 7 ❌
Notification.requestPermission() promise ✅ 47 ✅ ✅ 46 ✅ 15 ✅ 16.4 ⚠️
showNotification() / getNotifications() ✅ 42 / 40 ✅ ✅ 44 ✅ 16.1 (macOS 13 Ventura; MDN lists 16) ✅ 16.4 ⚠️
notificationclick / notificationclose ✅ 40 / 50 ✅ ✅ 44 ✅ 16 ⚠️ ⚠️ 16.4
actions, Notification.maxActions ✅ 48 (max 2) ✅ (max 2) ✅ 152 (max 2) ❌ ❌
badge ✅ 53 ✅ ❌ ❌ ❌
image ✅ 56 (not macOS) ✅ ❌ ❌ ❌
renotify ✅ 50 ✅ ❌ ❌ ❌
requireInteraction ✅ 47 ⚠️ no effect ⚠️ 117 Windows only ❌ ❌
silent ✅ 43 ✅ ✅ 132 ✅ 16.6 ❌ (per MDN; test on device)
timestamp ✅ 50 ✅ ❌ ❌ ❌
vibrate n/a ⚠️ ignored on Android 8+ ❌ ❌ ❌
tag replacement ✅ ✅ ✅ ❌ ❌
navigate option ❌ ❌ 🧪 ✅ 18.4 ✅ 18.4
Inline reply (type: "text", non-standard) ⚠️ ⚠️ ❌ ❌ ❌

Support data as of September 2026. ⚠️ on iOS: Home Screen web apps only, and notificationclick has open WebKit reliability bugs (268797, 282935) that MDN reflects by not listing it as supported. ⚠️ on macOS Safari events: a click can also fire notificationclose (WebKit bug 303532). ⚠️ on requireInteraction for Chrome desktop: no longer supported on macOS since Chrome 152. Edge follows the Chromium version numbers (Edge had its own EdgeHTML implementation from version 14). The Chromium inline-reply extension renders only on platforms with inline reply support. Check live data on MDN's Notifications API reference and caniuse.com.

Common pitfalls

  • Prompting on page load. Low acceptance, Chrome quiet UI enrollment, embargo after 3 dismissals, no prompt at all in Firefox and Safari without a gesture.
  • Awaiting before requestPermission(). Any await between the click and the call can consume the user activation in Firefox and Safari. Call it first.
  • Using new Notification() in a PWA. It throws on Android, does not exist on iOS, and its click handler dies with the tab.
  • Calling showNotification() before the worker is active. It rejects with TypeError. Use navigator.serviceWorker.ready.
  • Not passing the promise to waitUntil() in push handlers. The worker can stop before the icon fetch completes, and the notification never appears.
  • renotify without tag, or silent with vibrate. Both throw TypeError.
  • Forgetting notification.close() in notificationclick. The notification lingers on some platforms after being handled.
  • Slow work before openWindow(). Chromium's 10-second window-interaction allowance expires, and the window never opens.
  • Opening URLs straight from payload data. Validate that they are same-origin, or a compromised payload becomes an open redirect.
  • A full-color badge. Android renders it as a white blob.
  • Relying on tag replacement in Safari. Close old notifications explicitly.
  • Counting on notificationclose. It never fires for programmatic closes or replacements, and some platforms drop dismissals.
  • Assuming actions exist. Safari has none, and Android and Windows may collapse them. The body click must lead to every action.
  • Hot-linking icons from a CDN without caching. The notification waits for the fetch.
  • Treating denied as final forever. Users do re-enable notifications in settings. Watch permission changes with navigator.permissions.query() and resubscribe to push when it flips back to granted.

Debugging notifications

  • Chromium DevTools → Application → Background services → Notifications. Click Record and Chrome logs notification display, click and close events for up to three days, even with DevTools closed. The Push messaging panel does the same for push events. See Browser DevTools.
  • Trigger from the console. In the service worker's DevTools console, run self.registration.showNotification("Test", { body: "Hello", tag: "t" }) to test options and click handling without a server.
  • Check the OS before the code. Windows Do not disturb or Focus, macOS Focus and per-app notification settings, Android channel settings for your site, and the browser app's own OS permission all suppress notifications silently.
  • Check the site permission. chrome://settings/content/notifications in Chromium, Settings → Privacy & Security → Permissions → Notifications in Firefox, Safari → Settings → Websites → Notifications on macOS, and Settings → Notifications → [your app] on iOS.
  • Look for thrown TypeErrors. showNotification() rejects rather than throwing, so an unhandled rejection inside waitUntil() is easy to miss. Log rejections from every call.
  • macOS and Chrome 152+ PWAs. If notifications stopped appearing after an update, check System Settings → Notifications for the PWA's own entry. It is a separate app now.

Further reading

On this site

External references