# The Notifications API: system notifications

> How a PWA shows OS-level notifications: the permission model, persistent versus non-persistent notifications, and why mobile means persistent ones.

**In one line:** the Notifications API allows web pages to control the display of **system**
notifications to the end user. A web notification is a message box rendered by the operating
system's own native notification system, so it displays identically to notifications from any
other app on the platform — and because the OS renders it, it sits *outside* the top-level
browsing context viewport and can be shown even when the user has switched tabs or moved to a
different app. It is the *display* half of notifications; **Web Push** is the *delivery* half
that wakes your service worker when the page is closed.

The API is available only in secure contexts (HTTPS), in some or all supporting browsers, and it
is available in Web Workers.

## Notifications API vs Web Push

- **Notifications API** — creates and shows a notification. The interface is exposed to both
  `Window` and `Worker`, and the constructor is prohibited only in `ServiceWorkerGlobalScope`,
  so a non-persistent notification can come from a page or from another worker context; a
  service worker uses `showNotification()` instead.
- **Web Push** — lets a server deliver a message to the user's device even when the site is
  closed; the browser wakes the service worker, which then calls the Notifications API to
  show something. The division of labour is the thing to remember: the Notifications API
  provides the display, while Web Push enables server-initiated delivery when the app is closed.

Use the Notifications API directly for in-session alerts; pair it with
[Web Push](/reference/notifications/web-push/) for messages that must arrive when the app
isn't open.

## Persistent vs non-persistent notifications

The spec draws the line by service worker registration: a **non-persistent** notification is
one whose service worker registration is *null*, and a **persistent** notification is one whose
registration is *non-null*. That single distinction decides almost everything else about how a
notification behaves.

| | Non-persistent | Persistent |
|---|---|---|
| Created by | `new Notification(title, options)` | `registration.showNotification(title, options)` |
| Created in | any scope the constructor is allowed in — `Window` or a non-service-worker `Worker`; MDN describes the common case as a browsing context such as a web page or tab | a `Window` or `Worker` context with a `ServiceWorkerRegistration` |
| Lifetime | tied to the creating context; MDN's wording is that if the page is closed the notification can no longer be interacted with | can remain interactive beyond the lifetime of an individual page |
| Events | `show`, `click`, `close` fired on the `Notification` object | `notificationclick` and `notificationclose` fired on the `ServiceWorkerGlobalScope` |
| Notification center | user agents **should not** display it in a platform's "notification center" (if available) | user agents **should** display it there |
| Action buttons | the constructor throws a `TypeError` if `options["actions"]` is not empty | supported (see the per-browser table below) |
| Represented by | exactly one `Notification` object | zero or more `Notification` objects |

Two consequences are worth stating plainly:

- **Non-persistent notifications are transient by design.** User agents *should* run the close
  steps for a non-persistent notification a couple of seconds after it was created, and *should*
  keep it out of the notification center; persistent notifications are the ones user agents
  *should* display there. These are `should`-level recommendations rather than absolute
  requirements, so treat them as the design intent: if a notification is meant to survive long
  enough for the user to come back to it, the persistent path is the one written for that.
- **On mobile, persistent is the portable choice.** MDN's guidance is direct: if your code needs
  to run on mobile devices then you must use persistent notifications, because the
  `Notification()` constructor will throw a `TypeError` on **most** mobile browsers. "Most" is
  the right word — browser-compat-data records Chrome for Android as always throwing, while
  Firefox for Android mirrors desktop Firefox, where the constructor is supported. Writing to
  the persistent path is what makes the code portable across that split.

```js
// Persistent: the path that works across the mobile split above.
// `ready` is the intended "delay until a worker is active" helper — use it once you
// know this page registers one. It is the wrong tool in a *detection* path, where it
// never rejects and waits indefinitely; see "How to detect it at runtime" below.
async function notify(title, body) {
  const registration = await navigator.serviceWorker.ready;
  await registration.showNotification(title, { body, data: { url: '/inbox' } });
}
```

Handle activation in the service worker, not the page:

```js
// service-worker.js
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});
```

`ServiceWorkerRegistration.getNotifications()` returns a list of the notifications in the order
they were created from the current origin via the current service worker registration — useful
for coalescing or clearing what you have already shown.

## The permission model

Showing a notification requires the user to grant the current origin permission to display
system notifications. `Notification.permission` is one of three strings:

- `granted` — the user has explicitly granted permission for the current origin to display
  system notifications.
- `denied` — the user has explicitly denied it.
- `default` — the user decision is unknown; in this case the application will act as if
  permission was denied. Treat it as "cannot show anything yet" rather than as a guarantee that
  the user has never seen a prompt.

- Read the current state with `Notification.permission`.
- Request it with `Notification.requestPermission()`, which resolves to the resulting state.
- **Call it from a user gesture.** MDN states the method should only be called when handling a
  user gesture, such as when handling a mouse click.
- **The choice persists.** Once a choice has been made, the setting will generally persist for
  the current session — so after a denial you typically cannot re-prompt during that session.

```js
btn.addEventListener('click', async () => {
  // Guard first: on an iOS tab the interface is undefined, so reading it throws.
  if (!('Notification' in window)) return;
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    await notify('You are all set', 'We will let you know when something happens.');
  }
});
```

## Notification options

`options` commonly includes `body`, `icon`, `badge`, `tag` (to coalesce or replace
notifications), and `data` (a payload for the click handler). Two more are worth knowing:

- **`actions`** — action buttons. `Notification.maxActions` is a static getter whose steps are
  to return the maximum number of actions supported, so read it rather than assuming a count.
  Remember the constructor rejects a non-empty `actions` list with a `TypeError`.
- **`navigate`** — a URL that will be opened if the notification is accepted. When a `navigate`
  URL is set, a non-persistent notification does **not** fire a `click` event; the user agent
  navigates to that URL instead.

## Browser & ecosystem support

MDN marks the Notifications API's availability as **limited** — it is not Baseline, because it
does not work in some of the most widely-used browsers. The per-browser picture from MDN's
browser-compat-data is uneven in a way that matters more than the version numbers:

| Browser | `Notification` since | Caveat recorded in browser-compat-data |
|---|---|---|
| Chrome (desktop) | 20 | Since Chrome 49 notifications do not work in incognito mode |
| Chrome (Android) | 42 | Partial: a notification can only be sent from a service worker, and the constructor *always* throws a `TypeError` |
| Edge | 14 | — |
| Firefox | 22 | `actions` only from Firefox 152 |
| Safari (macOS) | 7 | `actions` not supported |
| Safari (iOS/iPadOS) | 16.4 | Partial: the `Notification` interface is **undefined** unless the page is a web app saved to the Home Screen, whose manifest has a non-default `display` value; a notification can only be sent from a service worker |
| Samsung Internet | 4.0 | Partial: available only through service workers |
| Android WebView / iOS WebView | not supported | — |

The iOS/iPadOS row is the one that surprises people, and it lines up with WebKit's own
announcement: Web Push — which drives notifications — arrived for web apps **added to the Home
Screen** in iOS and iPadOS 16.4, not for a site open in a Safari tab. See
[iOS and Safari Web Push](/reference/notifications/ios-safari-push/) for that platform's rules.

Support for `actions` and `navigate` lags the base interface: `actions` landed in Chrome 53,
Edge 18, Opera 39 and Firefox 152 and is not supported in Safari, while `navigate` is supported
in Safari 18.4 and not in Chrome.

## How to detect it at runtime

Because the interface can be entirely **undefined** (an iOS tab) and because the constructor can
throw even where the interface exists (Chrome on Android), probe for the persistent path and
branch explicitly when it is missing — do not assume a fallback to `new Notification()`.

One detail decides whether the fallback branch ever runs: `'serviceWorker' in navigator` proves
only that the *API* exists, not that this page has a usable worker.
`ServiceWorkerContainer.ready` is documented as a way of delaying code execution until a service
worker is active — MDN's contract is a promise that **will never reject** and that waits
*indefinitely* until the registration associated with the page has an active worker. Awaiting it
in a probe therefore has no bounded outcome: with nothing to activate, it neither fulfills nor
rejects and the in-page fallback never executes.

`getRegistration()` is the settling alternative — MDN describes it as resolving to a
`ServiceWorkerRegistration` or to `undefined` — but a registration on its own is not enough.
`ServiceWorkerRegistration.active` returns the worker whose state is activating or activated, and
MDN documents it as `null` when there is none; a registration can hold only an `installing` or
`waiting` worker, and per the Service Worker specification an installing worker can end up
`redundant` if installation fails, in which case no active worker ever appears. So check for the
active worker directly and return `null` when it is absent, rather than handing the wait off to
`ready`:

```js
async function getNotifier() {
  if (!('Notification' in window) || !('serviceWorker' in navigator)) {
    // No usable Notifications API — e.g. an iOS tab that is not a Home Screen web app.
    return null;
  }
  // getRegistration() settles either way; `ready` never rejects and waits indefinitely.
  const registration = await navigator.serviceWorker.getRegistration();
  // A registration may hold only an installing/waiting worker — and that install can
  // end up redundant — so require the active one instead of awaiting activation here.
  if (!registration || !registration.active) {
    return null;
  }
  if (!('showNotification' in registration)) {
    return null;
  }
  return registration;
}

async function alertUser(title, body) {
  const notifier = await getNotifier();
  if (!notifier) {
    // Fallback: keep the message in the page instead of dropping it.
    showInAppBanner(title, body);
    return;
  }
  if (Notification.permission !== 'granted') {
    showInAppBanner(title, body);
    return;
  }
  await notifier.showNotification(title, { body });
}
```

## Practical checklist

- [ ] Feature-detect `Notification` **and** `navigator.serviceWorker` before using either, and keep an in-page fallback for the branch where neither is there.
- [ ] In a detection path, resolve `navigator.serviceWorker.getRegistration()` and require `registration.active`; do not await `navigator.serviceWorker.ready`, which never rejects and waits indefinitely for an active worker that an `installing`/`waiting`-only (or failed) registration may never produce. If you genuinely must wait for activation, watch the worker's `statechange` for `activated` versus `redundant` behind your own timeout, and fall back when it expires.
- [ ] Prefer `registration.showNotification()` everywhere — it is the portable mobile path, the one user agents should surface in the notification center, and the only one that accepts `actions`.
- [ ] Never pass `actions` to `new Notification()`: a non-empty list throws a `TypeError`.
- [ ] Read `Notification.maxActions` instead of hard-coding how many buttons you can show.
- [ ] Request permission from a user gesture, in context, after the user opts into alerts.
- [ ] After a denial, hide the feature rather than re-prompting during the session — the setting generally persists for it.
- [ ] Handle clicks in the service worker's `notificationclick` event; focus or open the right page.
- [ ] On iOS and iPadOS, gate the whole feature behind a Home Screen web app whose manifest sets a non-default `display` value.
- [ ] Do not ship notifications as a WebView feature: Android and iOS WebViews do not support the interface.
- [ ] Pair with Web Push when notifications must arrive while the app is closed.

## Where to go next

- [Web Push](/reference/notifications/web-push/) — the delivery half: VAPID keys, subscriptions
  and the server-side send path.
- [iOS and Safari Web Push](/reference/notifications/ios-safari-push/) — the Home Screen
  requirement and the WebKit rules behind the iOS row above.
- [Notification permissions](/reference/notifications/permissions/) — how to ask, and what a
  denied answer means.
- [Notification actions and badges](/reference/notifications/notification-actions-badge/) —
  action buttons and app-icon badging in practice.