# iOS and Safari Web Push (16.4+)

> Web Push on iOS, iPadOS and Safari: the Home Screen requirement, the gesture-bound prompt, VAPID subscriptions, and the WebKit rules to design around.

import CompatTable from '@components/CompatTable.astro';

**In one line:** Web Push is the W3C combination of the **Push API**, the **Notifications API**
and **service workers** that lets a site notify a user when the site is not open; WebKit added it
for Home Screen web apps on **iOS and iPadOS 16.4**, where the permission request must come from
direct user interaction and the subscription must promise a visible notification. Since **iOS and
iPadOS 18.4** there is a second flavour — **Declarative Web Push** — which subscribes and displays
notifications *without* requiring an installed service worker.

## What shipped in 16.4

- **Standards-based push.** WebKit implemented the same W3C standards it shipped on macOS, so an
  app already coded to the standards — using feature detection instead of browser detection —
  works on iPhone and iPad automatically.
- **iOS/iPadOS requires a Home Screen web app.** A web app *added to the Home Screen* can request
  permission to receive push notifications; that is the surface WebKit added push to.
- **Notifications behave like any other app's.** They appear on the Lock Screen, in Notification
  Center and on a paired Apple Watch, and users manage them per web app in Notifications Settings.
- **Focus and Badging came with it.** Home Screen web app notifications integrate with Focus, and
  16.4 added the [Badging API](/reference/installation/badging/) so a web app can set its icon
  badge count.
- **APNs does the delivery.** Web Push on iOS and iPadOS uses the same Apple Push Notification
  service that powers native push. You do **not** need to be a member of the Apple Developer
  Program, but if you control your server's push endpoints, allow URLs from `*.push.apple.com`.

## Where it is supported

iOS and iPadOS 16.4 is the floor on iPhone and iPad: support arrived with that release, for web
apps added to the Home Screen. On the desktop the timeline is earlier and the install step does
not apply — WebKit's iOS/iPadOS 16.4 article identifies the macOS release as **Safari 16.1 on
macOS Ventura** (the earlier announcement said Safari 16), where the implementation relies on a
system daemon (`webpushd`) so a push can be delivered even when Safari is not running.

<CompatTable feature="web-push" />

### What "16.4, Home Screen only" looks like in code

MDN's browser-compat-data records the iOS/iPadOS entry for the `Notification` interface as
**16.4, partial**, and the two notes it attaches are the concrete shape of the install gate:

- **The interface is undefined unless the page is a web app saved to the Home Screen**, and the
  app's manifest must have a **non-default `display` value**. So on iOS this is not a
  "permission denied" situation you can recover from — in a plain Safari tab the symbol is not
  there at all, and touching the constructor throws a `ReferenceError`.
- **A notification can only be sent from a service worker** on iOS and iPadOS: use
  `ServiceWorkerRegistration.showNotification()`, not `new Notification()`.

Those two notes explain why a push integration that works on desktop Safari can appear to be
completely absent on iPhone: the manifest `display` value is doing load-bearing work, and a
page-scoped `new Notification()` has no supported path on the platform at all.

## The flow on iOS (original Web Push)

This is the service-worker-based path WebKit shipped in 16.4. For the service-worker-free
alternative added in 18.4, see [Declarative Web Push](#the-second-flavour-declarative-web-push-184)
below.

1. **Install first.** The user adds your web app to the Home Screen from the Share menu. In 16.4
   third-party browsers can offer "Add to Home Screen" too, and a site whose manifest sets
   `display` to `standalone` or `fullscreen` opens as a web app no matter which browser added it.
2. **Ask from direct user interaction.** Inside the installed web app, call
   `Notification.requestPermission()` in response to a real interaction — WebKit describes this as
   a tap on a "subscribe" button the web app provides. iOS or iPadOS then shows the system prompt.
3. **Subscribe with VAPID.** Call `pushManager.subscribe()` with `userVisibleOnly: true` and your
   application server key, and persist the returned `PushSubscription` (its `endpoint` plus the
   `p256dh` and `auth` keys) server-side.
4. **Send and show.** Your server posts an encrypted message to the `endpoint`; the `push` event
   starts your service worker, where you call `showNotification()`.

The permission request is the first thing that can fail, and on iOS it fails *hard*: as the row
above records, the `Notification` interface is undefined in a plain tab, so
`Notification.requestPermission()` throws a `ReferenceError` before any `subscribe()` call — a
`try` wrapped around `subscribe()` alone never sees it. Check the interface first, and keep the
permission request inside the same guarded path:

```js
// Inside the installed web app, from a user-interaction handler:
async function enablePush(swReg, applicationServerKey) {
  // On iOS/iPadOS the interface is absent outside a Home Screen web app, so reading
  // it in a plain tab throws a ReferenceError. Route that case to install guidance.
  if (!('Notification' in window)) {
    showAddToHomeScreenGuidance();
    return null;
  }
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;
  try {
    return await swReg.pushManager.subscribe({
      userVisibleOnly: true, // required: every push must show a notification
      applicationServerKey,  // VAPID key — no APNs key in client code
    });
  } catch (error) {
    // A NotAllowedError can mean denied permission, a non-HTTPS window scope, or
    // userVisibleOnly: false where the user agent requires true. It does not establish
    // that the app is not installed; the Notification guard above detects the iOS gate.
    reportSubscriptionFailure(error);
    return null;
  }
}
```

## The second flavour: Declarative Web Push (18.4+)

The steps above describe **original Web Push**, the JavaScript-first design in which a service
worker registration creates the subscription and a `PushEvent` handler calls `showNotification()`.
WebKit shipped **Declarative Web Push** on **iOS and iPadOS 18.4** for web apps added to the Home
Screen, and on **macOS from Safari 18.5** (the release that shipped with macOS 15.5). It lets you
request a push subscription and display user-visible notifications **without requiring an installed
service worker**.

Two differences matter when you write code:

- **A different `PushManager`.** The only `PushManager` available with original Web Push is
  `ServiceWorkerRegistration.pushManager`. Declarative Web Push *also* exposes
  `window.pushManager`, to support subscription management without a service worker. If you do
  also have a service worker registered at the root scope of your domain, it shares the same push
  subscription as the `window` object — and removing that registration does not affect the
  subscription.
- **A declarative message format.** For a notification to be handled declaratively, the push
  message must match the declarative standard JSON format (a `"web_push": 8030` member plus a
  `notification` object), so the browser has enough information to display the notification
  without any JavaScript. Service worker JavaScript may optionally change the contents of an
  incoming notification.

```js
// Declarative Web Push: no service worker registration required.
const subscription = await window.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: arrayForPublicKey,
});
```

## Detecting support and falling back

Feature-detect rather than sniffing for Safari — WebKit's own guidance is that an app using
feature detection picks up push on iPhone and iPad as soon as it ships. Prefer the declarative,
window-scoped manager when it is there, and only then fall back to the service-worker-scoped one
that original Web Push requires:

Probe for a usable *manager*, not for the `PushManager` interface: the presence of the global
interface does not by itself mean this context can subscribe. Note what this does **not** do —
no client-side probe below establishes Home Screen status, so on iOS a plain Safari tab can pass
these checks and still fail later. The `Notification` guard in `enablePush()` above is what
catches that tab; a `subscribe()` rejection is *not* a reliable install signal on its own,
because the same rejection channel carries key and subscription errors (see the error-name split
above).

```js
async function getPushManager() {
  // Declarative Web Push (18.4+): subscribe without an installed service worker.
  if ('pushManager' in window) return window.pushManager;

  if (!('serviceWorker' in navigator)) {
    // Nothing to subscribe with at all. Fall back to in-app messaging and show
    // install guidance instead of failing.
    showAddToHomeScreenGuidance();
    return null;
  }
  // Original Web Push: the subscription lives on the service worker registration.
  // Ask for the registration — `navigator.serviceWorker.ready` never rejects and waits
  // indefinitely for an active worker, which would hang here instead of reaching the
  // guidance below. Require `active`: subscribe() rejects with "InvalidStateError"
  // when the registration has no active worker.
  const swReg = await navigator.serviceWorker.getRegistration();
  if (!swReg || !swReg.active || !('pushManager' in swReg)) {
    showAddToHomeScreenGuidance();
    return null;
  }
  return swReg.pushManager;
}
```

`navigator.serviceWorker.ready` is the wrong probe here even though the API exists. MDN documents
it as a promise that **never rejects** and waits *indefinitely* until the page's registration has
an active worker. On a context that exposes the service worker API but has no registration — or
has one holding only an `installing`/`waiting` worker, which the Service Worker specification
lets become `redundant` if installation fails — and no `window.pushManager`, awaiting it stalls
the function forever and the install guidance promised above never appears. `getRegistration()`
settles either way (MDN: it resolves to a `ServiceWorkerRegistration` or `undefined`), and
`ServiceWorkerRegistration.active` is `null` until a worker reaches activating/activated — so
reading `active` gives you a bounded answer where `ready` gives you a wait. If you genuinely need
to wait for activation, watch the worker's `statechange` for `activated` versus `redundant`
behind your own timeout rather than awaiting `ready`.

## Constraints to design around

- **The prompt needs an explicit user gesture.** Requesting a push subscription requires one, so a
  prompt fired on page load has nowhere to land. Ask in context, after the user does something
  push helps with.
- **Silent push is not allowed.** `userVisibleOnly: true` is required in both flavours. Under
  **original Web Push** you must keep that promise in JavaScript: if a `PushEvent` handler does
  not show the user-visible notification, for any reason, WebKit **revokes** the subscription —
  and a service worker bug, network conditions or local device conditions can all prevent a timely
  `showNotification()` call. Under **Declarative Web Push** there is no such penalty for a service
  worker failing to display a notification, because the declarative push message itself is used as
  a fallback when the optional JavaScript step fails. Push is not a licence for silent background
  runtime either way.
- **The endpoint is a capability URL.** MDN's warning is that knowledge of the endpoint is all
  that is necessary to send a message to your application, so it needs to be kept secret —
  otherwise other applications might be able to send push messages to your app.
- **Budget for delivery limits.** Waking a service worker costs battery, and browsers differ:
  Firefox applies a quota to push messages that do not generate notifications, Chrome sets no
  limit, and there is no standard mechanism.
- **Test on real hardware.** The install gate, the prompt and delivery are system-level behaviours
  — verify on an actual iPhone or iPad running 16.4 or later.

## Decision framework

| Decision question | Recommended action | Rationale |
|---|---|---|
| Can I push to an iOS user in a browser tab? | No — require the Home Screen web app first. | 16.4 added Web Push for web apps added to the Home Screen. |
| What key do I subscribe with? | A standard VAPID `applicationServerKey`. | WebKit implements the same W3C Push API; no APNs key client-side. |
| Do I need an Apple Developer Program membership? | No. | WebKit states membership is not required to send Web Push. |
| When do I prompt? | From direct user interaction, inside the installed web app. | A push subscription request requires an explicit user gesture. |
| macOS Safari without install? | Supported since Safari 16.1 on macOS Ventura. | The Home Screen step is the iOS/iPadOS path. |
| Can I push without showing a notification? | No. | `userVisibleOnly: true` is mandatory; under original Web Push a handler that fails to show one has its subscription revoked. |
| Do I have to register a service worker? | Not for Declarative Web Push on 18.4+. | Declarative Web Push subscribes and displays notifications without an installed service worker; original Web Push has only `ServiceWorkerRegistration.pushManager`. |

## Practical checklist

- [ ] Gate the push prompt behind a Home Screen web app on iOS/iPadOS, and show install guidance otherwise.
- [ ] Request permission only from direct user interaction, in context.
- [ ] Subscribe with `userVisibleOnly: true` and a VAPID `applicationServerKey`.
- [ ] Persist the full `PushSubscription` (endpoint + `p256dh` + `auth`) server-side, and treat the endpoint as a secret.
- [ ] On the original Web Push path, always call `showNotification()` in the `push` handler — a missed notification can cost you the subscription.
- [ ] To go declarative on 18.4+, subscribe via `window.pushManager` and send messages in the declarative JSON format, so no service worker is required.
- [ ] Allow `*.push.apple.com` if you restrict outbound push endpoints.
- [ ] Use feature detection, never browser detection — and probe for the manager you will actually use (`window.pushManager` for the declarative path, the registration's `pushManager` for the original one) rather than treating the bare `PushManager` interface as proof of availability in this context.
- [ ] Resolve the registration with `navigator.serviceWorker.getRegistration()` and require `registration.active`, rather than awaiting `navigator.serviceWorker.ready`: `ready` never rejects and waits indefinitely for an active worker, so the probe hangs instead of showing install guidance.
- [ ] Check `'Notification' in window` **before** calling `Notification.requestPermission()` — in a plain iOS tab the interface is undefined and reading it throws a `ReferenceError` that a `try` around `subscribe()` will never catch.
- [ ] Do not treat a `subscribe()` rejection as an installation signal. `NotAllowedError` can mean denied push permission, a non-HTTPS window scope, or `userVisibleOnly: false` where the user agent requires true. On iOS, use the absence of the `Notification` interface outside an eligible Home Screen web app for the install guidance branch.
- [ ] Give the manifest a non-default `display` value — on iOS the `Notification` interface stays undefined without one.
- [ ] Show notifications from the service worker (`registration.showNotification()`); `new Notification()` has no supported path on iOS or iPadOS.
- [ ] Verify the whole flow on a real iOS 16.4+ device.

## Where to go next

- [Web Push](/reference/notifications/web-push/) — the cross-browser protocol, VAPID keys and
  server-side send path this page builds on.
- [iOS Add to Home Screen](/reference/installation/ios-add-to-home-screen/) — the install step
  that unlocks push on iPhone and iPad.
- [Notification permissions](/reference/notifications/permissions/) — how to ask, and what a
  denied answer means.
- [The Notifications API](/reference/notifications/notifications-api/) — the display half, and
  the persistent-vs-non-persistent distinction the iOS service-worker requirement follows from.