Skip to content

Web Push on iOS & Safari

Safari supports standards-based Web Push (the Push API, Notifications API and service workers, delivered over RFC 8030 with VAPID) on macOS since Safari 16.1 on macOS Ventura, and on iPhone and iPad since iOS and iPadOS 16.4, but only for web apps added to the Home Screen. Apple routes every message through the Apple Push Notification service (APNs), needs no Apple Developer account, and enforces rules that other browsers don't, most importantly that every push must produce a visible notification. This page covers where push works on Apple platforms, the exact requirements, the silent push penalty and how WebKit implements it, Apple's push service errors, Declarative Web Push (Safari 18.4 and later), what changed in the EU and in iOS 26, and how to debug on a real device.

Key takeaways

  • iOS and iPadOS: Home Screen web apps only. In a Safari tab on iPhone or iPad, Notification is undefined and you can't subscribe. From iOS 26, any site added to the Home Screen with Open as Web App switched on (the default) qualifies.
  • Permission needs a user gesture. WebKit consumes transient activation when prompting. Without it, pushManager.subscribe() rejects with NotAllowedError and Notification.requestPermission() resolves "denied" without showing a prompt.
  • Every push must show a notification. Current WebKit gives your service worker 30 seconds to call showNotification(). On the third push without one, it removes the push subscriptions for that origin. The counter never counts down.
  • Standard protocol, Apple's service. Endpoints are on a subdomain of push.apple.com. The server uses ordinary VAPID (RFC 8292) and aes128gcm encryption (RFC 8291). Apple's service returns specific reason codes such as BadJwtToken and VapidPkHashMismatch, and caps payloads at 4 KB.
  • Declarative Web Push (Safari 18.4 on iOS, 18.5 on macOS) lets the browser show a notification from JSON ("web_push": 8030) without running JavaScript. It's exempt from the silent push penalty and falls back cleanly on other browsers if your service worker parses the same JSON.
  • Debug on a device. Connect it to a Mac, open Safari's Develop menu, and inspect the Home Screen web app and its service worker. Safari 26 can open an inspector automatically when a new service worker starts. Log Apple's apns-id and reason on the server.

Where web push works on Apple platforms

Apple ships three browser-level push paths today. You need to know which one your users are on, because the rules differ.

Context First version How the user gets there Push available?
Safari tab on macOS Safari 16.1 on macOS 13 Ventura (October 24, 2022) Normal browsing ✅ Any HTTPS site. Safari 16.1 and later on Big Sur or Monterey has no web push.
Web app on Mac (Add to Dock) Safari 17.0 on macOS 14 Sonoma (September 2023) File > Add to Dock or the Share menu ✅ Separate subscription, permission and storage from Safari
Safari tab on iOS / iPadOS – Normal browsing ❌ Notification is undefined; there's no way to subscribe
Home Screen web app on iOS / iPadOS iOS / iPadOS 16.4 (March 27, 2023) Share > Add to Home Screen, from Safari or another browser ✅ Each installed copy is its own app
In-app browsers (SFSafariViewController, WKWebView) – Links opened inside other apps ❌ MDN lists no support in iOS WebViews
Declarative Web Push Safari 18.4 on iOS / iPadOS 18.4 (March 31, 2025); Safari 18.5 on macOS (May 12, 2025) Same contexts as above ✅ In addition to the service worker path

Before Safari 16.1, macOS Safari only had Apple's proprietary Safari Push Notifications, introduced in OS X Mavericks. That system uses window.safari.pushNotification, a signed "push package", certificates and an Apple Developer account. It is unrelated to the standard Push API. If you find it in an old codebase, replace it with the standard flow on this page. Nothing on this page needs an Apple Developer account.

A short timeline of Web Push on Apple platforms

Date Release Push-related change
June 7, 2022 WWDC22, WebKit's Meet Web Push Standards-based Web Push announced for Safari; userVisibleOnly required, no developer account
October 24, 2022 Safari 16.1 "Added Web Push Notifications support on macOS Ventura"
March 27, 2023 iOS / iPadOS 16.4 Web Push for Home Screen web apps, Badging API, manifest id, Add to Home Screen for third-party browsers
September 2023 Safari 17.0 Web apps on Mac (Sonoma) with push and badging; Web Push subscriptions scoped per Safari profile
December 11, 2023 Safari 17.2 Cookies copied when saving to the Home Screen; fixed notification clicks more than 30 seconds after delivery failing to open the web app
March 2024 iOS 17.4 EU betas turned Home Screen web apps into bookmarks; Apple reversed this before release (see the EU section)
May 13, 2024 Safari 17.5 "Fixed several issues that caused Web Push to not show notifications when the web app or Safari was not already running"
December 11, 2024 Safari 18.2 "Fixed pushManager.subscribe returning an empty endpoint"
March 31, 2025 Safari 18.4 Declarative Web Push for Home Screen web apps
May 12, 2025 Safari 18.5 Declarative Web Push on macOS
September 15, 2025 Safari 26.0 / iOS 26 Every site added to the Home Screen opens as a web app by default; automatic service worker inspection in Web Inspector
December 12, 2025 Safari 26.2 Declarative mutable read from the top-level object, as specified
September 14, 2026 Safari 27.0 No push-specific changes in the release notes

The bug fixes matter when you support older devices. If a user is on iOS 17.4 or earlier and reports that notifications only arrive while the app is open, the 17.5 fix is the first thing to check.

How Apple delivers a push message

Safari implements the same W3C Push API as other browsers, but the pieces behind it are Apple's. The push service is APNs, the service that delivers notifications to native apps. On the device, a system daemon (webpushd in WebKit's source) holds the subscriptions, receives messages, decrypts them, wakes the right web app and enforces the silent push rule.

sequenceDiagram
    participant App as Web app page
    participant SW as Service worker
    participant D as webpushd on device
    participant APNs as Apple push service
    participant S as Your server
    App->>D: pushManager.subscribe(userVisibleOnly, key)
    D->>APNs: register topic with VAPID public key
    APNs-->>D: endpoint on push.apple.com
    D-->>App: PushSubscription (endpoint, p256dh, auth)
    App->>S: POST subscription JSON
    S->>APNs: POST endpoint (VAPID JWT, aes128gcm body)
    APNs-->>S: 201 Created + apns-id
    APNs->>D: deliver message
    D->>D: decrypt, check permission and app
    D->>SW: push event (starts worker if needed)
    SW->>D: showNotification() within 30 s
    D->>D: display on Lock Screen, Notification Center, Watch

Things to take from that diagram:

  • Endpoints are Apple URLs. A Safari subscription's endpoint is on a subdomain of push.apple.com (in practice https://web.push.apple.com/…). Apple's documentation tells you to allow https://*.push.apple.com if your network restricts outbound traffic. Don't hard-code the host; parse it from the endpoint.
  • VAPID is the only authentication. Apple's docs say plainly: "You don't need to join the Apple Developer Program to send web push notifications." There are no APNs certificates, .p8 keys or team IDs involved.
  • The device checks the app before running your code. On iOS, WebKit's daemon ignores a message when the Home Screen web app is no longer on the device, and ignores it when the notification permission in Settings is not authorized. Your server still gets 201 from APNs. From its point of view the message was accepted.
  • Each install is a separate app. iOS has always allowed several copies of the same web app on one device. Each copy gets its own storage, permission and subscription. WebKit keys the copy by the manifest id plus the name the user typed, and uses that pair to sync Focus settings across devices.
  • macOS keeps Safari and Dock apps apart. When a user adds a site to the Dock, Safari copies its cookies into the web app, "Safari does not copy over any other kind of local storage". A subscription made in a Safari tab doesn't carry over. The user has to subscribe again inside the Dock app. Safari profiles (Safari 17 and later) also scope subscriptions per profile.

For the protocol itself (message encryption, VAPID JWT construction, headers, retries), see The Web Push Protocol. For the cross-browser client and service worker flow, see Push Notifications. This page concentrates on what is different on Apple platforms.

Requirements on iPhone and iPad

Five conditions must all hold before a Home Screen web app on iOS or iPadOS can subscribe.

1. The site runs as a Home Screen web app

Up to iOS 18, a Home Screen icon only opened as a web app if the site asked for it, with a manifest whose display is standalone or fullscreen, or the legacy <meta name="apple-mobile-web-app-capable" content="yes">. Anything else became a bookmark that opened in the browser, and bookmarks have no push.

iOS and iPadOS 26 reversed the default. WebKit's Safari 26 announcement: "By default, every website added to the Home Screen opens as a web app. If the user prefers to add a bookmark for their browser, they can disable 'Open as Web App' when adding to Home Screen." A manifest is no longer required for the app to be a web app, although you still want one for the name, icons, start_url, scope and id. The practical rule on iOS 26 and later: push works if the user left Open as Web App switched on.

Web apps added from another browser are the same kind of Home Screen web app. Since iOS 16.4, browsers such as Chrome, Edge and Firefox on iOS can offer Add to Home Screen from their share menus. They run on WebKit and use APNs, whichever browser created them.

You can tell at runtime whether you're inside one:

standalone.js
// True inside an iOS/iPadOS Home Screen web app (and, since Safari 17, a Dock web
// app on macOS). navigator.standalone is a non-standard WebKit property that doesn't
// exist in most other browsers. Check it first on Apple platforms.
export const isAppleWebApp = navigator.standalone === true;

// Standard check for other browsers. Not reliable on iOS: a web app whose manifest
// says "standalone" matches display-mode: fullscreen there (WebKit bug 264218),
// and one without a manifest reports "browser".
export const isStandaloneDisplay = window.matchMedia("(display-mode: standalone)").matches;

// iOS/iPadOS Safari tab: WebKit exposes navigator.standalone === false and
// no Notification interface. This is the state where you show install help.
export const needsHomeScreenInstall =
  "standalone" in navigator && navigator.standalone === false && !("Notification" in window);

Detecting Installed Apps covers installation detection across browsers in more depth.

2. A service worker, or window.pushManager

The classic path subscribes through ServiceWorkerRegistration.pushManager and needs an active service worker. WebKit rejects with InvalidStateError ("Subscribing for push requires an active service worker") if the registration has no active worker yet. Always go through navigator.serviceWorker.ready, which resolves only once a worker is active for the page's scope.

From Safari 18.4 on iOS and iPadOS (and 18.5 on macOS), window.pushManager also exists. It subscribes without any service worker and is intended for Declarative Web Push.

3. Permission requested from a user gesture

Apple's instructions: "Provide a method for the user to grant permission with a gesture, such as clicking or tapping a button. When the user completes the gesture, call the push subscription method immediately from the gesture's event handler code." WebKit enforces this with transient activation, the same mechanism that gates pop-ups:

  • pushManager.subscribe() checks the permission first. If it is "default", WebKit tries to consume the window's transient activation. If there is none, it logs "Push notification prompting can only be done from a user gesture." and rejects with NotAllowedError.
  • Notification.requestPermission() does the same consume-or-fail check. Without activation it logs "Notification prompting can only be done from a user gesture." and resolves with "denied" without showing any prompt. Notification.permission stays "default", which confuses code that trusts the promise result.
  • Once permission is "granted", subscribe() no longer needs a gesture. You can quietly resubscribe on launch, which is what you need after WebKit removes a subscription (see below).
  • If permission is "default" and you call subscribe() from a service worker, WebKit rejects with NotAllowedError ("User denied push permission"). The prompt can only come from a document.
  • A cross-origin iframe can't prompt: "Cannot request permission from cross-origin iframe".

Consuming activation means one gesture buys you one prompt. Two practical consequences:

  1. Don't do slow work before the call. If your click handler first awaits a network request (for example, to fetch the VAPID key), activation may have expired by the time you call subscribe(). Load the key and the service worker registration before the user taps.
  2. Pick one prompt. Call pushManager.subscribe() directly; it shows the notification prompt itself. If you call Notification.requestPermission() first, that consumes the gesture. It still works, because subscribe() no longer needs activation after the grant, but only if the user accepted.

4. userVisibleOnly: true and an applicationServerKey

WebKit validates the options before it looks at permission. The exact rejections, in the order WebKit checks them:

Condition Rejection WebKit's message
userVisibleOnly missing or false NotAllowedError "Subscribing for push requires userVisibleOnly to be true"
applicationServerKey missing NotSupportedError "Subscribing for push requires an applicationServerKey"
Key is a string that isn't valid base64url InvalidCharacterError "applicationServerKey is not properly base64url-encoded"
Key isn't a valid uncompressed P-256 point (65 bytes starting 0x04) InvalidAccessError "applicationServerKey must contain a valid P-256 public key"
No active service worker InvalidStateError "Subscribing for push requires an active service worker"
Permission "denied" NotAllowedError "User denied push permission"
Permission "default" and no transient activation NotAllowedError "Push notification prompting can only be done from a user gesture."
User dismisses or rejects the prompt NotAllowedError "User denied push permission"

One more rule comes from the Push API specification rather than WebKit: if a subscription already exists and you call subscribe() with a different applicationServerKey, the promise rejects with InvalidStateError. After rotating VAPID keys, unsubscribe first.

5. HTTPS

The Push API is [SecureContext]. Home Screen web apps are always loaded over HTTPS in production. For local development, test on a real HTTPS host or a tunnel. An IP address or .local hostname over HTTP won't work on the device.

Client code: subscribing in Safari and everywhere else

The module below is the complete client side. It feature-detects rather than sniffing user agents, shows install help in an iOS Safari tab, keeps the subscription in sync on every launch, and uses the service worker path when a worker exists. It targets every engine; nothing in it is Safari-only except the optional window.pushManager fallback.

push-client.js
// Web Push client: Safari (macOS, iOS/iPadOS Home Screen web apps), Chromium, Firefox.
const VAPID_PUBLIC_KEY = "REPLACE_WITH_YOUR_BASE64URL_VAPID_PUBLIC_KEY";
const SUBSCRIPTIONS_URL = "/api/push/subscriptions";

// Resolve the registration early, at startup, so the click handler never waits
// on it. WebKit consumes transient activation, so slow work before subscribe()
// can make the prompt fail.
const registrationPromise =
  "serviceWorker" in navigator
    ? navigator.serviceWorker
        .register("/sw.js", { scope: "/" })
        .then(() => navigator.serviceWorker.ready)
        .catch((error) => {
          // Registration failed (bad script, private mode quirks). Don't leave an
          // unhandled rejection; getPushManager() falls back to window.pushManager.
          console.warn("Service worker registration failed", error);
          return null;
        })
    : Promise.resolve(null);

function base64UrlToBytes(base64Url) {
  const padding = "=".repeat((4 - (base64Url.length % 4)) % 4);
  const base64 = (base64Url + padding).replace(/-/g, "+").replace(/_/g, "/");
  const raw = atob(base64);
  const bytes = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i += 1) bytes[i] = raw.charCodeAt(i);
  return bytes;
}

const applicationServerKey = base64UrlToBytes(VAPID_PUBLIC_KEY);

export function getPushCapability() {
  const hasNotification = "Notification" in window;
  const hasWorkerPush = "serviceWorker" in navigator && "PushManager" in window;
  const hasWindowPush = "pushManager" in window; // Declarative Web Push (Safari 18.4+)
  return {
    supported: hasNotification && (hasWorkerPush || hasWindowPush),
    // iOS/iPadOS Safari tab: the user must add the site to the Home Screen first.
    needsHomeScreenInstall:
      "standalone" in navigator && navigator.standalone === false && !hasNotification,
    permission: hasNotification ? Notification.permission : "unsupported",
  };
}

async function getPushManager() {
  const registration = await registrationPromise;
  if (registration?.pushManager) return registration.pushManager;
  if ("pushManager" in window) return window.pushManager; // SW-less declarative path
  throw new Error("Push is not supported in this context");
}

function sameKey(subscription) {
  const existing = subscription.options?.applicationServerKey;
  if (!existing) return true; // Older engines don't expose options; assume a match.
  const a = new Uint8Array(existing);
  return a.length === applicationServerKey.length && a.every((b, i) => b === applicationServerKey[i]);
}

async function saveSubscription(subscription) {
  const response = await fetch(SUBSCRIPTIONS_URL, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      subscription: subscription.toJSON(), // { endpoint, expirationTime, keys: { p256dh, auth } }
      standalone: navigator.standalone === true,
    }),
  });
  if (!response.ok) throw new Error(`Saving subscription failed: HTTP ${response.status}`);
}

// Call this directly from a click/tap handler. Do no slow work before it.
export async function subscribeFromGesture() {
  const pushManager = await getPushManager(); // already resolved: no real wait
  let subscription = await pushManager.getSubscription();
  if (subscription && !sameKey(subscription)) {
    await subscription.unsubscribe(); // a different key would throw InvalidStateError
    subscription = null;
  }
  if (!subscription) {
    // Shows the permission prompt when permission is "default".
    subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
  }
  await saveSubscription(subscription);
  return subscription;
}

// Call on every launch. WebKit can delete subscriptions (silent push penalty,
// data removal) and iOS never fires pushsubscriptionchange, so re-check here.
export async function resyncSubscription() {
  if (!("Notification" in window) || Notification.permission !== "granted") return null;
  try {
    const pushManager = await getPushManager();
    let subscription = await pushManager.getSubscription();
    if (!subscription || !sameKey(subscription)) {
      if (subscription) await subscription.unsubscribe();
      // Permission is already granted, so WebKit doesn't need a gesture here.
      subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
    }
    await saveSubscription(subscription); // idempotent upsert on the server
    return subscription;
  } catch (error) {
    console.warn("Push resync failed", error);
    return null;
  }
}

export async function unsubscribe() {
  const pushManager = await getPushManager();
  const subscription = await pushManager.getSubscription();
  if (!subscription) return;
  await fetch(SUBSCRIPTIONS_URL, {
    method: "DELETE",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ endpoint: subscription.endpoint }),
  });
  await subscription.unsubscribe();
}

Wire it to a real button. The handler calls subscribeFromGesture() synchronously on click, and the install hint only appears in an iOS or iPadOS Safari tab:

app.js
import { getPushCapability, subscribeFromGesture, resyncSubscription } from "./push-client.js";

const button = document.querySelector("#enable-notifications");
const installHint = document.querySelector("#install-hint");
const status = document.querySelector("#push-status");

function render() {
  const cap = getPushCapability();
  installHint.hidden = !cap.needsHomeScreenInstall;
  button.hidden = !cap.supported || cap.permission === "granted" || cap.permission === "denied";
  status.textContent =
    cap.permission === "denied"
      ? "Notifications are blocked. Turn them on in Settings > Notifications."
      : "";
}

button.addEventListener("click", async () => {
  button.disabled = true;
  try {
    await subscribeFromGesture();
    status.textContent = "Notifications are on.";
  } catch (error) {
    // NotAllowedError covers "denied", a dismissed prompt and a missing gesture.
    status.textContent =
      error.name === "NotAllowedError"
        ? "Notifications were not allowed."
        : "Couldn't turn on notifications. Try again later.";
    console.error(error);
  } finally {
    button.disabled = false;
    render();
  }
});

render();
resyncSubscription(); // keeps the server's copy fresh on every launch
index.html (excerpt)
<button id="enable-notifications" type="button" hidden>Turn on notifications</button>
<p id="install-hint" hidden>
  To get notifications on iPhone or iPad, tap <strong>Share</strong>, then
  <strong>Add to Home Screen</strong>, keep <strong>Open as Web App</strong> on,
  and open the app from your Home Screen.
</p>
<p id="push-status" role="status"></p>

Only ask for permission after the user has seen why notifications are useful, and never on page load. On iOS there's a second reason besides good UX: a prompt that's triggered without a gesture doesn't even appear.

The service worker: always show a notification

A service worker for Safari looks like one for any other browser, with one non-negotiable property: every push event must end with a visible notification, even when parsing fails, the network is down or the message turned out to be stale. The worker below handles three kinds of message:

  • a classic push whose event.data holds JSON (all browsers),
  • a mutable Declarative Web Push message, where Safari 18.4+ hands you a proposed event.notification and event.data is null,
  • anything malformed, which still produces a generic notification.

It also sets the app badge, focuses or opens the right window on click, and resubscribes on pushsubscriptionchange where that event exists.

sw.js
// Service worker for Web Push on Safari (macOS + iOS/iPadOS web apps) and other browsers.
// The server always sends the declarative JSON shape:
// { "web_push": 8030, "notification": { title, body, navigate, tag, data, ... }, "app_badge": 3 }
const FALLBACK_TITLE = "New activity";
const FALLBACK_URL = "/";

// This is a push-only worker with no fetch handler or caches, so activating a new
// version immediately can't break open pages. A worker that also serves cached
// assets must not call skipWaiting() unconditionally (see lifecycle.md, pitfalls.md).
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));

self.addEventListener("push", (event) => {
  // One promise for the whole handler. If it rejects, WebKit treats the event
  // as failed; if no notification was shown, it also counts a silent push.
  event.waitUntil(handlePush(event));
});

async function handlePush(event) {
  // Safari 18.4+: a *mutable* declarative message arrives with a proposed
  // Notification and no data. If this function shows nothing, Safari displays
  // the proposed notification itself, so there is no penalty here.
  if (event.notification) {
    await refineDeclarative(event);
    return;
  }

  let message = null;
  try {
    message = event.data ? event.data.json() : null;
  } catch {
    message = null; // Not JSON: fall through to a generic notification.
  }

  const { title, options, badge } = toNotification(message);

  try {
    await self.registration.showNotification(title, options);
  } catch (error) {
    // showNotification() can reject (for example on bad option values).
    // Show *something* or WebKit counts this push as silent.
    await self.registration.showNotification(FALLBACK_TITLE, {
      body: "Open the app to see what's new.",
      tag: "fallback",
      data: { url: FALLBACK_URL },
    });
  }

  await updateBadge(badge);
}

function toNotification(message) {
  const n = message?.notification ?? {};
  const url = typeof n.navigate === "string" ? n.navigate : FALLBACK_URL;
  return {
    title: typeof n.title === "string" && n.title ? n.title : FALLBACK_TITLE,
    options: {
      body: typeof n.body === "string" ? n.body : "",
      tag: typeof n.tag === "string" ? n.tag : undefined,
      lang: typeof n.lang === "string" ? n.lang : undefined,
      dir: ["auto", "ltr", "rtl"].includes(n.dir) ? n.dir : "auto",
      silent: typeof n.silent === "boolean" ? n.silent : undefined,
      icon: typeof n.icon === "string" ? n.icon : undefined, // ignored by Safari
      // Keep the URL in data for browsers that don't support `navigate`.
      data: { ...(typeof n.data === "object" && n.data ? n.data : {}), url },
    },
    badge: Number.isSafeInteger(message?.app_badge) ? message.app_badge : undefined,
  };
}

async function refineDeclarative(event) {
  const proposed = event.notification;
  try {
    // Example: personalize from local state, with a hard time budget.
    const unread = await withTimeout(readUnreadCountFromCache(), 3000);
    await self.registration.showNotification(proposed.title, {
      body: unread > 1 ? `${proposed.body} (+${unread - 1} more)` : proposed.body,
      tag: proposed.tag || undefined,
      lang: proposed.lang || undefined,
      dir: proposed.dir,
      data: proposed.data,
      navigate: proposed.navigate, // Safari 18.4+: opens this URL, no notificationclick
    });
  } catch {
    // Do nothing: Safari falls back to the proposed notification.
  }
}

async function updateBadge(count) {
  if (!("setAppBadge" in self.navigator) || count === undefined) return;
  try {
    // On iOS the badge only appears once notification permission is granted.
    if (count > 0) await self.navigator.setAppBadge(count);
    else await self.navigator.clearAppBadge();
  } catch {
    // Badging unsupported or not allowed here. Never let it break the push.
  }
}

function withTimeout(promise, ms) {
  return Promise.race([
    promise,
    new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)),
  ]);
}

// The page stores the latest server count with
//   caches.open("app-state").then((c) => c.put("/__state/unread",
//     new Response(JSON.stringify({ unreadCount }))));
// Cache Storage is shared between the page and the worker, and needs no schema.
async function readUnreadCountFromCache() {
  const cache = await caches.open("app-state");
  const response = await cache.match("/__state/unread");
  if (!response) return 1; // nothing stored yet: treat this push as the only item
  const { unreadCount } = await response.json();
  return Number.isSafeInteger(unreadCount) && unreadCount > 0 ? unreadCount : 1;
}

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = new URL(event.notification.data?.url ?? FALLBACK_URL, self.location.origin);
  event.waitUntil(focusOrOpen(target.href));
});

async function focusOrOpen(url) {
  const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
  for (const client of windows) {
    if (new URL(client.url).origin !== self.location.origin) continue;
    await client.focus();
    // navigate() only works for clients this worker controls.
    if ("navigate" in client && client.url !== url) {
      try {
        await client.navigate(url);
      } catch {
        client.postMessage({ type: "open-url", url });
      }
    }
    return;
  }
  await self.clients.openWindow(url);
}

// Fires in Safari on macOS, Chromium 138+ and Firefox; not on iOS. The launch-time
// resync in push-client.js covers the platforms that never fire it.
self.addEventListener("pushsubscriptionchange", (event) => {
  event.waitUntil(
    (async () => {
      const options = event.oldSubscription?.options;
      if (!options?.applicationServerKey) return; // let the page resync on next launch
      const subscription = await self.registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: options.applicationServerKey,
      });
      await fetch("/api/push/subscriptions", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          subscription: subscription.toJSON(),
          replaces: event.oldSubscription?.endpoint ?? null,
        }),
      });
    })(),
  );
});

Three details in that file are specific to Apple platforms:

  1. showNotification() is called on every path. WebKit records whether showNotification() ran during the event. If it didn't, the console shows "Push event handling completed without showing any notification via ServiceWorkerRegistration.showNotification(). This may trigger removal of the push subscription."
  2. The badge is set after the notification. iOS allows setAppBadge() while the web app handles a push in the background, but a badge failure must never prevent the notification.
  3. notificationclick isn't your only route back into the app. A notification shown with the navigate option (Safari 18.4+) opens that URL directly and, per the Notifications standard, does not fire notificationclick. MDN's compatibility data also lists notificationclick as unsupported on iOS. Make sure the URL the notification opens is a real deep link that renders the right screen on its own, rather than relying on a click handler to route the user.

The silent push rule and how WebKit enforces it

The Push API has a userVisibleOnly flag. When it's true, the site promises that each push produces something the user can see. Chromium also requires true, but applies its own, more lenient policy when a site breaks the promise. WebKit's policy is strict. Apple's documentation: "Safari doesn't support invisible push notifications. Present push notifications to the user immediately after your service worker receives them. If you don't, Safari revokes the push notification permission for your site."

The WebKit source shows what that means today:

Mechanism Value in current WebKit source Where
Time allowed to call showNotification() after the message reaches the worker 30 seconds (silentPushTimeoutForProduction) NotificationData.h
Silent pushes allowed before subscriptions are removed Fewer than 3 (maxSilentPushCount = 3): the third silent push removes them WebPushDaemonConstants.h, PushService.mm
Counter reset None. The count is stored per web app and origin and only starts again at 0 when a new subscription set is created PushDatabase.cpp
Exemption while debugging Enforcement is suspended while the origin's service worker is open in Web Inspector WebPushDaemon.mm
Declarative Web Push messages Never counted as silent pushes WebPushDaemon.mm

These are implementation details, not documented guarantees, and Apple can change them. Design as if the first silent push might cost you the subscription.

flowchart TD
    A["Push arrives for the web app"] --> B{"Valid declarative JSON?"}
    B -- "Yes" --> C["Never a silent push"]
    B -- "No" --> D["push event dispatched, 30 s timer starts"]
    D --> E{"showNotification() called in time?"}
    E -- "Yes" --> F["Timer cleared"]
    E -- "No" --> G{"Worker open in Web Inspector?"}
    G -- "Yes" --> H["Logged only"]
    G -- "No" --> I["silentPushCount + 1"]
    I --> J{"Count reached 3?"}
    J -- "No" --> K["Subscription kept"]
    J -- "Yes" --> L["All subscriptions for this app and origin removed"]

What removal looks like from each side:

  • In the app, pushManager.getSubscription() resolves null. pushsubscriptionchange isn't available on iOS, so you only find out when your code checks. That's why resyncSubscription() runs on every launch.
  • On the server, messages to the old endpoint stop being delivered. Treat 404 and 410 responses as a dead subscription and delete it (see Apple's response codes).
  • The notification permission itself is a separate setting from the subscription. If it's still "granted", your launch-time resync can subscribe again without a prompt. If the user sees no notifications for weeks, they may never open the app, so avoid triggering this in the first place.

Patterns that cause accidental silent pushes

Pattern Why it's silent Fix
"Data sync" pushes that update a cache without notifying No notification by design Don't use push for background sync on Safari. Sync on launch, or use Background Sync where it exists (Chromium only)
Skipping the notification when the app is in the foreground clients.matchAll() finds a focused window, so the worker returns early Always show one; use a tag and a short body, or update in-app UI and show the notification
Deduplication that drops a push already shown The second push shows nothing Show it again with the same tag, which replaces the earlier notification instead of adding one
event.data.json() throws on a non-JSON or empty payload Handler rejects before showNotification() Wrap parsing in try/catch and fall back to a generic title
fetch() for notification content, then show Slow or offline network runs out the 30 s budget Put the content in the payload (up to about 4 KB), or show first and refine later
showNotification() not awaited inside waitUntil() The worker can be stopped before the call completes Return the whole chain from waitUntil()
A promise passed to waitUntil() rejects after the notification was shown WebKit marks the event as failed Catch errors from non-critical work such as analytics and badges
Mutable declarative message and a handler that awaits setAppBadge() first See the note below Call showNotification() first; don't await the badge on the critical path

Badging inside a mutable declarative push event

In current WebKit source, calling navigator.setAppBadge() in a service worker while a mutable declarative push event is being handled doesn't talk to the OS directly. It records the value as the event's updated badge, which Safari applies when the event completes. The promise returned in that code path is never settled. If you await it before calling showNotification(), your handler stalls, and Safari falls back to the proposed notification. Set the badge through the payload's app_badge member instead, or call setAppBadge() without awaiting it.

Sending to Apple's push service

Your server sends exactly the same RFC 8030 request it sends to Chrome's or Firefox's push services. Apple documents a few extra constraints.

Headers Apple accepts

Header Required Apple's rules
TTL Yes Seconds the message may wait while the device is offline. APNs stores it "for 30 days or fewer", and "the number of notifications the push services stores while the device is offline is limited". A missing or non-positive value is BadTtl
Authorization Yes vapid t=<JWT>, k=<public key>. The key must match the applicationServerKey used to subscribe. "Don't refresh your JWT more frequently than once per hour."
Content-Encoding With a body aes128gcm (RFC 8291). May be omitted when there's no payload. WebKit's PushManager.supportedContentEncodings is ["aesgcm", "aes128gcm"], but use aes128gcm
Topic No Coalescing key: at most 32 characters from the URL-safe Base64 alphabet. A newer message with the same topic replaces an undelivered older one. Invalid values give BadWebPushTopic
Urgency No very-low, low, normal or high. Use high "to attempt to deliver the notification immediately". Anything else gives BadUrgency

Apple's service accepts HTTP/1.1 (the default) and HTTP/2, negotiated with ALPN. Clients must send SNI. Over HTTP/1.1 with pipelining, don't have more than 100 unacknowledged requests on one connection. Over HTTP/2, respect the server's SETTINGS_MAX_CONCURRENT_STREAMS.

VAPID JWT rules that trip people up

Apple returns BadJwtToken when the JWT is missing, signed with the wrong key, has a sub that isn't a URL or mailto:, has an aud that "isn't the origin of the push service where you sent the request", or has an exp "more than one day into the future". In practice:

  • aud must be computed per endpoint. For Safari subscriptions it is the endpoint's origin, such as https://web.push.apple.com. Servers that hard-code Google's or Mozilla's origin fail only for Safari users.
  • exp must be at most 24 hours ahead, which RFC 8292 requires anyway. Use 12 hours to leave room for clock skew.
  • sub must parse as a URL. "mailto: [email protected]" (with a space) isn't one. The web-push library warns that a localhost subject "is unsupported by Apple's push notification server and will result in a BadJwtToken error". Use a real https: URL or mailto: address.
  • Reuse the JWT. Apple asks you not to refresh it more than once an hour. Libraries that sign a new token for every message still work in practice, but at volume, cache one token per audience.

A Node.js sender that follows Apple's rules

This sender uses the web-push package (3.x) for payload encryption. It passes a cached Authorization header instead of letting the library sign a new JWT for every request.

send-push.mjs
import webpush from "web-push";

const VAPID_SUBJECT = process.env.VAPID_SUBJECT; // e.g. "mailto:[email protected]"
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY; // base64url, 65 bytes decoded
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY; // base64url, 32 bytes decoded

const JWT_LIFETIME_S = 12 * 60 * 60; // under the 24 h maximum
const JWT_REUSE_S = 6 * 60 * 60; // re-sign well before expiry, never more than hourly
const authCache = new Map(); // audience -> { header, reuseUntil }

function vapidAuthorization(endpoint) {
  const audience = new URL(endpoint).origin; // https://web.push.apple.com for Safari
  const now = Math.floor(Date.now() / 1000);
  const cached = authCache.get(audience);
  if (cached && cached.reuseUntil > now) return cached.header;

  const { Authorization } = webpush.getVapidHeaders(
    audience,
    VAPID_SUBJECT,
    VAPID_PUBLIC_KEY,
    VAPID_PRIVATE_KEY,
    "aes128gcm",
    now + JWT_LIFETIME_S,
  );
  authCache.set(audience, { header: Authorization, reuseUntil: now + JWT_REUSE_S });
  return Authorization;
}

// RFC 8291: a 4096-byte push message holds at most 3993 bytes of plaintext.
const MAX_PLAINTEXT_BYTES = 3993;

export async function sendPush(subscription, message, { ttl = 3600, urgency = "normal", topic } = {}) {
  const body = JSON.stringify(message);
  if (Buffer.byteLength(body, "utf8") > MAX_PLAINTEXT_BYTES) {
    throw new Error("Payload too large for Web Push; send an ID and fetch details on click");
  }

  try {
    const result = await webpush.sendNotification(subscription, body, {
      vapidDetails: null, // falsy: skip the library's own JWT signing
      headers: { Authorization: vapidAuthorization(subscription.endpoint) },
      TTL: ttl,
      urgency,
      ...(topic ? { topic } : {}),
      timeout: 10_000,
    });
    return { ok: true, status: result.statusCode, apnsId: result.headers["apns-id"] ?? null };
  } catch (error) {
    if (!(error instanceof webpush.WebPushError)) throw error; // network/TLS problem
    let reason = null;
    try {
      reason = JSON.parse(error.body)?.reason ?? null; // Apple: { "reason": "BadJwtToken" }
    } catch {
      // Other push services return plain text or HTML bodies.
    }
    return {
      ok: false,
      status: error.statusCode,
      reason,
      apnsId: error.headers?.["apns-id"] ?? null,
      // 404/410: the subscription is gone. Delete it.
      expired: error.statusCode === 404 || error.statusCode === 410,
      // 429/5xx: retry later with backoff. 400/403/413: fix the request.
      retryable: error.statusCode === 429 || error.statusCode >= 500,
    };
  }
}

Build the message in the declarative format even if you don't use Declarative Web Push yet. Every browser can parse it in a service worker, and Safari 18.4+ can display it without one:

notify-new-message.mjs
import { sendPush } from "./send-push.mjs";

export async function notifyNewMessage(subscription, { threadId, sender, preview, unreadCount }) {
  const url = `https://app.example.com/threads/${encodeURIComponent(threadId)}`;
  const message = {
    web_push: 8030,
    notification: {
      title: sender,
      body: preview.slice(0, 180),
      navigate: url, // absolute URL: WebKit rejects relative ones
      tag: `thread-${threadId}`,
      lang: "en-US",
      dir: "auto",
      silent: false,
      data: { threadId, url },
      mutable: true, // Safari 18.4-26.1 read mutable here
    },
    mutable: true, // standard location; Safari 26.2+ reads it here first
    app_badge: unreadCount, // standard proposal: top level
  };
  return sendPush(subscription, message, {
    ttl: 24 * 60 * 60,
    urgency: "high",
    topic: `t${threadId}`.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 32),
  });
}

Reading Apple's responses

Apple's response always includes an apns-id header that uniquely identifies the request. Log it, along with the HTTP status and the JSON reason, for every failure.

Status Meaning What to do
201 Accepted Nothing. Acceptance isn't delivery: iOS may still drop the message on the device (app deleted, notifications off)
400 Bad request Fix the request; see reason
403 Authentication error Check the VAPID key pair, aud, exp and sub
404 Invalid :path The endpoint isn't valid; delete the subscription
405 Method isn't POST Fix the client code
410 "The device token has expired" Delete the subscription
413 Payload too large Keep payloads under 4 KB (3993 bytes of plaintext)
429 Too many requests for the same destination Back off and retry later; collapse bursts with Topic
500 Internal server error Retry with exponential backoff
503 Server shutting down or unavailable Retry on a new connection
reason Cause according to Apple
BadTtl TTL header missing or not a positive number
BadUrgency Urgency present but not very-low, low, normal or high
BadWebPushRequest Request doesn't conform to the encryption rules
BadWebPushTopic Topic present but doesn't conform to the specification
VapidPkHashMismatch The VAPID public key in the request doesn't match the one the subscription was created with
BadAuthorizationHeader Authorization header doesn't conform to the specification
BadJwtToken JWT missing, wrong signing key, bad sub, wrong aud, or exp more than a day ahead
BadVapidPublicKey VAPID public key missing, not base64url, or the wrong key type
BadPath Invalid :path
MethodNotAllowed :method isn't POST
PayloadTooLarge Payload over the 4 KB limit
TooManyRequests Too many consecutive requests to the same device token
IdleTimeout The connection timed out
InternalServerError, ServiceUnavailable, Shutdown Server-side conditions; retry

VapidPkHashMismatch almost always means you rotated VAPID keys, or you run several environments that share a subscription database but have different keys. Keep one key pair per subscription, store which key each subscription was created with, and resubscribe clients through resyncSubscription() when you rotate.

Declarative Web Push (Safari 18.4 and later)

Declarative Web Push is an addition to the Push API, designed at Apple and merged into the W3C Push API editor's draft in 2025. It lets the push message itself describe the notification, so the browser can show it without starting a service worker. WebKit's announcement ("Meet Declarative Web Push", March 27, 2025) gives two motivations:

  • The silent push penalty. Bugs, bad network conditions or device conditions can prevent a timely showNotification() call, and the site loses its subscription. With a declarative message there's always something to show, so the penalty doesn't apply.
  • Tracking prevention. WebKit's Intelligent Tracking Prevention can delete website data, including service worker registrations, for sites the user hasn't used for a while. Classic push dies with the service worker. A declarative subscription made through window.pushManager survives, and "the removal of that service worker registration will not affect the associated push subscription".

Message format

A declarative push message is an ordinary encrypted push whose plaintext is JSON of this shape:

declarative-message.json
{
  "web_push": 8030,
  "notification": {
    "title": "Ada emailed 'London'",
    "body": "Did you hear about the tube strikes?",
    "navigate": "https://email.example/message/12",
    "lang": "en-US",
    "dir": "ltr",
    "tag": "message-12",
    "icon": "https://email.example/icons/mail-192.png",
    "silent": false,
    "data": { "messageId": 12 }
  },
  "mutable": false,
  "app_badge": 4
}
Member Required Type Specification WebKit parser
web_push Yes Integer 8030 (a nod to RFC 8030) Opts the message into declarative parsing ✅
notification Yes Object – ✅
notification.title Yes String Any string ✅ Must be non-empty
notification.navigate Yes URL string Parsed against the subscription scope ✅ Must be an absolute URL
notification.body, lang, tag No String – ✅
notification.dir No "auto", "ltr", "rtl" Invalid values ignored ✅ Invalid values make the whole message fail
notification.icon No URL string – ✅ Must be an absolute URL (Safari shows the app icon regardless)
notification.silent No Boolean – ✅
notification.data No Any JSON – ✅
notification.image, badge, vibrate, timestamp, renotify, requireInteraction No Various Defined ❌ Ignored
notification.actions[] (action, title, navigate, icon) No Array Each action needs its own navigate ❌ Ignored
mutable No Boolean, default false Top level ✅ Top level since Safari 26.2; inside notification in 18.4-26.1 (still accepted)
app_badge No Integer ≥ 0 (WebKit also accepts a digit string) Proposed (push-api PR #402), top level ✅ Top level in current WebKit; inside notification in the original 18.4 implementation

Two placement changes happened after the first release. WebKit moved app_badge from inside notification to the top level in May 2025 (WebKit bug 293457), and Safari 26.2 fixed mutable to be read from the top level, keeping the old location as a fallback. Neither parser rejects unknown members, so the robust choice for a mixed install base is what the sender above does: put mutable in both places, and put app_badge at the top level (plus inside notification if you still care about the earliest 18.4 builds).

WebKit's parser is stricter than the specification. Where the spec ignores a bad optional member, WebKit rejects the whole declarative message when a member has the wrong type (body as a number, silent as a string), dir isn't one of the three values, navigate or icon isn't a valid absolute URL, mutable isn't a boolean, or app_badge is negative, fractional or a string with anything other than digits. A rejected message isn't dropped. It's delivered as a classic push to your service worker's push event, with event.data holding the raw JSON, and the silent push rule applies again. Validate the JSON on the server before sending. A subscription made through window.pushManager with no service worker has nothing to handle that fallback at all.

How Safari processes a declarative message

flowchart TD
    A["Decrypted push payload"] --> B{"JSON with web_push 8030?"}
    B -- "No" --> L["Classic path: push event with event.data"]
    B -- "Yes" --> C{"Valid notification?"}
    C -- "No" --> L
    C -- "Yes" --> D{"mutable true and a service worker exists?"}
    D -- "No" --> E["Show notification and apply app_badge, no JavaScript"]
    D -- "Yes" --> F["push event: event.notification set, event.data null"]
    F --> G{"showNotification() called?"}
    G -- "Yes" --> H["Show the replacement"]
    G -- "No, error or timeout" --> E

On iOS, an immutable declarative message is displayed by the system daemon directly. No web content process or service worker starts at all. That makes declarative messages cheaper for the battery than classic ones, as well as safer for you.

Mutable messages: refining the notification in the service worker

With "mutable": true, Safari dispatches a normal push event to the service worker whose scope matches, but the event looks different:

Property Classic push Mutable declarative push
event.data PushMessageData or null null
event.notification null (or undefined in other browsers) A Notification built from the JSON: title, body, tag, data, navigate, …
event.appBadge – Recent WebKit builds: the proposed app_badge value (part of the PR #402 proposal)
Calling showNotification() Required Optional: replaces the proposed notification
Not calling it, or throwing Counts as a silent push Proposed notification shown; no penalty

This mirrors the UNNotificationServiceExtension model in native iOS apps: the payload is always displayable, and the code only gets a short window to improve it, for example to decrypt an end-to-end encrypted preview with a key that only exists on the device.

Subscribing without a service worker

window.pushManager is the same PushManager interface, attached to Window:

declarative-subscribe.js
// Safari 18.4+ only. Still needs a user gesture when permission is "default".
async function subscribeDeclarative(applicationServerKey) {
  if (!("pushManager" in window)) return null; // fall back to the service worker path
  return window.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey });
}

The specification scopes a window subscription to the origin's root (/). If you also register a service worker with scope /, it shares the same subscription, so window.pushManager.getSubscription() and registration.pushManager.getSubscription() return the same endpoint. A service worker with a narrower scope (such as /app/) has its own, separate subscription. Without a service worker, clicks can only navigate (to navigate), and there's no way to run code when a message arrives.

The navigate option outside declarative messages

The same Safari release added navigate to NotificationOptions, so a classic service worker can use it too:

sw.js (excerpt)
await self.registration.showNotification("Order shipped", {
  body: "Your package is on its way.",
  navigate: "https://shop.example/orders/8812", // Safari 18.4+: opened on click
  data: { url: "/orders/8812" }, // used by notificationclick in other browsers
});

Following the Notifications standard's activation steps, when a notification (or the clicked action) has a navigation URL, the browser opens it and returns without firing notificationclick. Browsers that don't know the option ignore it and fire notificationclick as usual, which is why the example keeps the URL in data as well.

Support outside Safari

At the time of writing, only Safari ships Declarative Web Push. MDN's compatibility data lists Window.pushManager and PushEvent.notification as Safari-only, and Notification.navigate as a Firefox preview feature. Chromium has no shipped implementation. Because the message is just JSON inside a normal push, sending the declarative format to every browser costs nothing: Chrome and Firefox deliver it to your service worker like any other payload, and the toNotification() function above turns it into a notification.

What notifications look like on Apple platforms

Safari passes only part of NotificationOptions through to the system's notification center. MDN's data and WebKit's source agree on the following:

Option Safari macOS iOS / iPadOS web app Notes
title, body ✅ ✅ On iOS, WebKit adds a subtitle "from" plus the web app's name
data ✅ ✅ Round-trips to notificationclick and event.notification
tag ⚠️ ⚠️ MDN lists it as having no effect; don't rely on replacement by tag
silent ✅ 16.6+ ❌ per MDN See the sound defaults below; test on a device
icon ❌ ❌ Can be set, has no effect. The app's (or Safari's) icon is used
navigate ✅ 18.4+ ✅ 18.4+ Opens the URL without notificationclick
image, badge, actions, requireInteraction, renotify, vibrate, timestamp ❌ ❌ Ignored. WebKit registers its notification category with no actions

Sound defaults differ by platform. MDN lists silent as not supported on iOS. WebKit's source suggests the default sound is played on iOS unless silent is true, so test on a device before relying on either behavior. On macOS, the sound plays only when silent is explicitly false. Safari 17's release notes describe this as defaulting silent "to the platform convention". If you want a sound on the Mac, pass silent: false.

Notifications from Home Screen web apps behave like native app notifications. Apple says they "show on the Lock Screen, in Notification Center, and on a paired Apple Watch", and each web app has its own entry in Settings > Notifications, where the user can turn off alerts, sounds and badges separately. On the Mac, you manage per-site permissions for Safari tabs in Safari > Settings > Websites > Notifications, while each Dock web app is a separate app with its own notification settings.

Focus modes

Notifications from web apps integrate with Focus, so a user can allow or silence each web app per Focus. Apple: "For users who add the same web app to their Home Screen on more than one iOS or iPadOS device, Focus modes automatically apply to all of them." That sync keys off your manifest id together with the name the user gave the icon, so a stable id (see App Identity & Updates) keeps Focus settings attached to your app. There's no web API to read the current Focus or to mark a notification time-sensitive. A notification silenced by Focus has still been shown as far as WebKit is concerned, so it never counts as a silent push.

Badges

Home Screen web apps on iOS and iPadOS 16.4+ and web apps on Mac support navigator.setAppBadge() and clearAppBadge(). On iOS the badge is displayed only after the user grants notification permission, and the calls work while the app is in the foreground or handling a push. The Badging API page covers the API, the other platforms and unread-count patterns. For push specifically, send the count in app_badge and update it in the worker as shown above.

iOS 26 and iOS 27: what changed

iOS and iPadOS 26 (Safari 26.0, September 15, 2025) made every site a potential web app. With Open as Web App on by default, "there are now zero requirements for 'installability' in Safari". For push this has three effects:

  • A site without a manifest can now get push on iOS, provided it registers a service worker (or uses window.pushManager) and the user installed it with the toggle on.
  • The install step is still manual and still required. There's no API to trigger it, so your in-app instructions matter as much as before.
  • Users who switch Open as Web App off get a browser bookmark. It opens in Safari, where Notification is undefined. Your UI should detect that state (needsHomeScreenInstall) and explain it rather than showing a button that can't work.

Safari 26.0 also added Automatically Inspect New Service Workers and Automatically Pause New Service Workers to Web Inspector. They're described in Debugging web push on a real device.

iOS and iPadOS 27 (Safari 27.0, September 14, 2026) has no push-specific changes in its release notes. The only notification-related entry is a fix for URL parsing on the Notification object. The behavior on this page applies to iOS 26 and 27 alike. iOS & iPadOS tracks the wider platform changes.

The EU Digital Markets Act and Home Screen web apps

In February 2024, betas of iOS 17.4 in the European Union opened Home Screen web apps as ordinary bookmarks in the browser, which would have removed web push and badging for EU users. Apple's original explanation cited "the complex security and privacy concerns associated with web apps using alternative browser engines", which the DMA requires Apple to allow in the EU.

Apple reversed the decision before release. Its updated statement:

We have received requests to continue to offer support for Home Screen web apps in iOS, therefore we will continue to offer the existing Home Screen web apps capability in the EU. This support means Home Screen web apps continue to be built directly on WebKit and its security architecture, and align with the security and privacy model for native apps on iOS.

iOS 17.4 shipped in March 2024 with Home Screen web apps, and their push support, intact in the EU. What this means for you today:

  • Web push on iOS behaves the same inside and outside the EU.
  • Home Screen web apps run on WebKit even when the user's default browser in the EU uses another engine. Apple hasn't documented a way for such a browser to create Home Screen web apps that run on its own engine.
  • Messages still go through APNs, so the server side is identical everywhere.

Debugging web push on a real device

Push can't be fully tested in a desktop browser pretending to be an iPhone. You need the real system daemon, real APNs delivery and a real Home Screen install.

Connect Web Inspector

  1. On the iPhone or iPad, open Settings > Apps > Safari > Advanced (on iOS 17 and earlier, Settings > Safari > Advanced) and turn on Web Inspector.
  2. On the Mac, enable Safari > Settings > Advanced > Show features for web developers to get the Develop menu.
  3. Connect the device with a cable and trust the Mac. After that you can enable Connect via Network from the device's submenu in the Develop menu.
  4. Open the web app from the Home Screen. In Develop > your device, it appears in the Home Screen Web Apps section while it's in the foreground.
  5. A running service worker appears in the Service Workers section of the same submenu. That section "doesn't appear if there aren't any service workers currently running", and a worker woken by a push usually finishes before you can click it.
  6. In Safari 26 and later, open Develop > Inspect Apps and Devices, find the web app, and from its menu choose Automatically Inspect New Service Workers (and optionally Automatically Pause New Service Workers). The next time the worker starts, for example because a push arrived, an inspector window opens for it, paused if you asked, so you can set breakpoints in the push handler.

While a worker is being inspected, current WebKit doesn't count missed notifications towards the silent push limit. Test the "no notification" path with the inspector closed if you want to see the real penalty, and on a throwaway subscription.

Console messages worth knowing

Message Meaning
"Push notification prompting can only be done from a user gesture." subscribe() was called with permission "default" and no transient activation
"Notification prompting can only be done from a user gesture." Same for Notification.requestPermission(), which then resolves "denied"
"Subscribing for push requires userVisibleOnly to be true" Add userVisibleOnly: true
"Subscribing for push requires an active service worker" Await navigator.serviceWorker.ready first
"Push event handling completed without showing any notification via ServiceWorkerRegistration.showNotification(). This may trigger removal of the push subscription." A silent push: this counts towards the limit
"Push event ended without showing any notification may trigger removal of the push subscription." Logged for a mutable declarative event where the worker didn't replace the notification. Here the proposed notification is shown and there's no penalty

Test the pipeline end to end

Work from the outside in:

  1. Server to Apple. Send one message and log status, reason and apns-id. A 201 means the request is valid and APNs has it.
  2. Apple to device. Close the web app and lock the phone. If nothing arrives, check Settings > Notifications > your app (notifications allowed? Focus active?), that the Home Screen icon still exists, and that the device is online.
  3. Device to worker. Enable automatic service worker inspection and send again. Confirm that the push event fires and whether event.data or event.notification is set.
  4. Worker to notification. Step through to showNotification() and watch for rejections.

A diagnostics snippet you can paste into the inspector's console inside the web app saves a lot of guessing:

push-diagnostics.js
(async () => {
  const registration = await navigator.serviceWorker?.getRegistration();
  const pm = registration?.pushManager ?? window.pushManager;
  const subscription = await pm?.getSubscription();
  console.table({
    standalone: navigator.standalone ?? "n/a",
    displayModeStandalone: matchMedia("(display-mode: standalone)").matches,
    notificationApi: "Notification" in window,
    notificationPermission: window.Notification?.permission ?? "n/a",
    pushPermission: pm ? await pm.permissionState({ userVisibleOnly: true }) : "n/a",
    serviceWorkerScope: registration?.scope ?? "none",
    activeWorker: registration?.active?.state ?? "none",
    windowPushManager: "pushManager" in window,
    endpointHost: subscription ? new URL(subscription.endpoint).host : "no subscription",
    setAppBadge: "setAppBadge" in navigator,
  });
})();

On macOS, the same Develop menu lists Safari's service workers directly, and Safari > Settings > Websites > Notifications shows and resets per-site permissions. Resetting there is the quickest way to test the first-run prompt again. On iOS, deleting the Home Screen icon removes the app with its storage, permission and subscription. Reinstalling gives you a clean slate.

Browser DevTools covers Safari's Web Inspector alongside Chrome and Firefox tooling.

Common failure reasons and fixes

Symptom Likely cause Fix
Notification is undefined on iPhone Page is in a Safari tab, or the icon was added with Open as Web App off Explain Share > Add to Home Screen with the toggle on; detect with navigator.standalone === false
Button does nothing; permission stays "default" Prompt called without transient activation, or after slow awaits Call subscribe() straight from the click handler; prefetch key and registration
NotAllowedError from subscribe() with no prompt shown Missing userVisibleOnly: true, or permission already "denied" Pass the option; for "denied", send the user to Settings
InvalidStateError from subscribe() No active worker, or an existing subscription with a different key Await serviceWorker.ready; unsubscribe before changing keys
Works on Chrome, 403 BadJwtToken on Safari Wrong aud (hard-coded origin), exp more than 24 h out, invalid or localhost sub Compute aud from the endpoint; 12 h exp; real mailto: or https: subject
VapidPkHashMismatch Server signs with a different key pair than the subscription used Store the key ID per subscription; resubscribe clients after rotation
413 / PayloadTooLarge JSON over 3993 bytes after encryption overhead Send IDs and short previews; load details when the user opens the app
Notifications stop after a few days Silent pushes removed the subscription Always call showNotification(); resync on launch; consider Declarative Web Push
201 from Apple but nothing on the device App deleted, notifications disabled in Settings, Focus, or TTL too short while offline Check the device settings; use a realistic TTL
Only arrives when the app is open (iOS 17.4 or earlier) WebKit bug fixed in Safari 17.5 Ask users to update iOS
Declarative message handled like a classic push Relative navigate, bad dir, wrong type for a member, missing title Validate on the server; absolute URLs only
No sound on the Mac macOS default is silent unless silent: false Pass silent: false when a sound is appropriate
Subscribed in Safari, nothing in the Dock app Dock web apps have separate storage and subscriptions Resubscribe inside the web app
Clicking the notification doesn't run notificationclick Notification has a navigate URL Expected; make the URL a deep link
Outbound requests to Apple time out Egress firewall Allow https://*.push.apple.com

Browser support

Feature Safari macOS Safari iOS / iPadOS Chrome / Edge Firefox
Push API + push event ✅ 16.1 (macOS 13 Ventura)1 ✅ 16.4, Home Screen web apps only ✅ ✅
Web apps on Mac with push ✅ 17 (Sonoma+) – – –
pushsubscriptionchange ✅ 16 ❌ ⚠️ 1383 ✅ 442
Badging API ✅ 17, web apps only ✅ 16.4, Home Screen web apps ✅ 81 desktop ❌
Declarative Web Push (web_push: 8030) ✅ 18.5 ✅ 18.4 ❌ ❌
window.pushManager ✅ 18.54 ✅ 18.4 ❌ ❌
PushEvent.notification ✅ 18.4 ✅ 18.4 ❌ ❌
NotificationOptions.navigate ✅ 18.4 ✅ 18.4 ❌ 🧪 preview
Notification actions ❌ ❌ ✅ ✅ 152

Support data as of September 2026. For live data, see MDN's Push API compatibility tables and caniuse.

Further reading

On this site

External references


  1. Safari 16.1 on macOS 13 Ventura. MDN lists 16, with the note "Notifications are supported on macOS Ventura and later"; the Safari 16.1 release notes (October 24, 2022) are where support on Ventura was announced. ↩

  2. Firefox has fired the event since 44; oldSubscription and newSubscription are exposed since Firefox 137. ↩

  3. Chromium fires it only when notification permission is re-granted after a revocation dropped the subscription, and both oldSubscription and newSubscription are null. ↩

  4. MDN lists 18.4 for window.pushManager on macOS; Declarative Web Push, which it exists for, shipped in Safari 18.5 on macOS. ↩