Notifications · Concept
The Notifications API: system notifications
Published Updated
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
Section titled “Notifications API vs Web Push”- Notifications API — creates and shows a notification. The interface is exposed to both
WindowandWorker, and the constructor is prohibited only inServiceWorkerGlobalScope, so a non-persistent notification can come from a page or from another worker context; a service worker usesshowNotification()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 for messages that must arrive when the app isn’t open.
Persistent vs non-persistent notifications
Section titled “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 aTypeErroron 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.
// 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:
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
Section titled “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.
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
Section titled “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.maxActionsis 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-emptyactionslist with aTypeError.navigate— a URL that will be opened if the notification is accepted. When anavigateURL is set, a non-persistent notification does not fire aclickevent; the user agent navigates to that URL instead.
Browser & ecosystem support
Section titled “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 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
Section titled “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:
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
Section titled “Practical checklist”- Feature-detect
Notificationandnavigator.serviceWorkerbefore using either, and keep an in-page fallback for the branch where neither is there. - In a detection path, resolve
navigator.serviceWorker.getRegistration()and requireregistration.active; do not awaitnavigator.serviceWorker.ready, which never rejects and waits indefinitely for an active worker that aninstalling/waiting-only (or failed) registration may never produce. If you genuinely must wait for activation, watch the worker’sstatechangeforactivatedversusredundantbehind 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 acceptsactions. - Never pass
actionstonew Notification(): a non-empty list throws aTypeError. - Read
Notification.maxActionsinstead 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
notificationclickevent; 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
displayvalue. - 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
Section titled “Where to go next”- Web Push — the delivery half: VAPID keys, subscriptions and the server-side send path.
- iOS and Safari Web Push — the Home Screen requirement and the WebKit rules behind the iOS row above.
- Notification permissions — how to ask, and what a denied answer means.
- Notification actions and badges — action buttons and app-icon badging in practice.
Specifications
| Specification | Status |
|---|---|
| None. | |