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,
Notificationis 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 withNotAllowedErrorandNotification.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) andaes128gcmencryption (RFC 8291). Apple's service returns specificreasoncodes such asBadJwtTokenandVapidPkHashMismatch, 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-idandreasonon 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
endpointis on a subdomain ofpush.apple.com(in practicehttps://web.push.apple.com/…). Apple's documentation tells you to allowhttps://*.push.apple.comif 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,
.p8keys 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
201from 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
idplus 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:
// 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 withNotAllowedError.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.permissionstays"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 callsubscribe()from a service worker, WebKit rejects withNotAllowedError("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:
- 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 callsubscribe(). Load the key and the service worker registration before the user taps. - Pick one prompt. Call
pushManager.subscribe()directly; it shows the notification prompt itself. If you callNotification.requestPermission()first, that consumes the gesture. It still works, becausesubscribe()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.
// 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:
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
<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.dataholds JSON (all browsers), - a mutable Declarative Web Push message, where Safari 18.4+ hands you a proposed
event.notificationandevent.dataisnull, - 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.
// 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:
showNotification()is called on every path. WebKit records whethershowNotification()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."- 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. notificationclickisn't your only route back into the app. A notification shown with thenavigateoption (Safari 18.4+) opens that URL directly and, per the Notifications standard, does not firenotificationclick. MDN's compatibility data also listsnotificationclickas 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()resolvesnull.pushsubscriptionchangeisn't available on iOS, so you only find out when your code checks. That's whyresyncSubscription()runs on every launch. - On the server, messages to the old endpoint stop being delivered. Treat
404and410responses 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:
audmust be computed per endpoint. For Safari subscriptions it is the endpoint's origin, such ashttps://web.push.apple.com. Servers that hard-code Google's or Mozilla's origin fail only for Safari users.expmust be at most 24 hours ahead, which RFC 8292 requires anyway. Use 12 hours to leave room for clock skew.submust parse as a URL."mailto: [email protected]"(with a space) isn't one. Theweb-pushlibrary warns that alocalhostsubject "is unsupported by Apple's push notification server and will result in a BadJwtToken error". Use a realhttps:URL ormailto: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.
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:
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.pushManagersurvives, 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:
{
"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:
// 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:
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
Notificationis 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¶
- On the iPhone or iPad, open Settings > Apps > Safari > Advanced (on iOS 17 and earlier, Settings > Safari > Advanced) and turn on Web Inspector.
- On the Mac, enable Safari > Settings > Advanced > Show features for web developers to get the Develop menu.
- 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.
- 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.
- 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.
- 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
pushhandler.
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:
- Server to Apple. Send one message and log status,
reasonandapns-id. A201means the request is valid and APNs has it. - 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.
- Device to worker. Enable automatic service worker inspection and send again. Confirm that the
pushevent fires and whetherevent.dataorevent.notificationis set. - 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:
(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
- Push Notifications: the complete cross-browser push flow, from subscription to server
- The Web Push Protocol: RFC 8030, message encryption and VAPID in detail
- Notifications API: every
NotificationOptionsmember and cross-browser display differences - Badging API: app badges on iOS, macOS, Windows and ChromeOS
- iOS & iPadOS: storage, lifecycle and the other iOS web app constraints
- Installability Criteria: how Safari decides what becomes a web app
- App Identity & Updates: why a stable manifest
idmatters for Focus sync and multiple installs - Permissions: prompting strategy and permission states
External references
- Sending web push notifications in web apps and browsers: Apple's requirements, headers and error reasons
- Web Push for Web Apps on iOS and iPadOS and Meet Web Push: WebKit's announcements
- Meet Declarative Web Push and the WebKit explainer
- WebKit Features in Safari 26.0: every site as a web app, automatic service worker inspection
- Push API (W3C editor's draft): declarative push message parser and
Window.pushManager - Notifications API (WHATWG):
navigateand notification activation - RFC 8030, RFC 8291 and RFC 8292: the protocol, message encryption and VAPID
- Inspecting iOS and iPadOS: Apple's Web Inspector setup guide
- Apple's DMA statement, March 2024 (archived): the Home Screen web apps reversal
-
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. ↩
-
Firefox has fired the event since 44;
oldSubscriptionandnewSubscriptionare exposed since Firefox 137. ↩ -
Chromium fires it only when notification permission is re-granted after a revocation dropped the subscription, and both
oldSubscriptionandnewSubscriptionarenull. ↩ -
MDN lists 18.4 for
window.pushManageron macOS; Declarative Web Push, which it exists for, shipped in Safari 18.5 on macOS. ↩