Web push notifications work on iPhone and iPad, but only inside a web app the user has added to the Home Screen. A page sitting in a Safari tab on iOS cannot even ask for permission. You need iOS 16.4 or later, a subscribe call fired straight from a tap, and a server that shows the user something on every single push.
That last rule has teeth. Apple’s documentation is blunt: “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.”
Why does the permission prompt never appear in Safari on iOS?
Because the Push API on iOS belongs to Home Screen web apps, not to tabs. WebKit shipped it in iOS and iPadOS 16.4 for web apps added through Share, then Add to Home Screen, and that restriction still stands. The Mac is different. Safari on macOS has accepted push in ordinary tabs since Safari 16, which is why a feature that “works on my MacBook” dies on the test iPhone.
The site used to have to look installable, too. Its manifest needed display set to standalone or fullscreen before iOS treated the shortcut as a web app rather than a bookmark. Safari 26.0 dropped that: “There are now zero requirements for ‘installability’ in Safari. Users can add any site to their Home Screen and open it as a web app on iOS26 and iPadOS26.” Ship the manifest regardless. It still sets the app name, the icons and the display mode, and a large share of devices sit on older versions for months.
So the order is manifest, Home Screen, then prompt. A subscribe button on a page that is not running as a web app is a dead control, and users blame you rather than Safari. Detect the case and say what to do instead:
const isWebApp = window.matchMedia('(display-mode: standalone)').matches;
const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent);
if (isIOS && !isWebApp) {
// Render "Add this to your Home Screen first", not a subscribe button.
}
How do you subscribe a user without losing the prompt?
Call it inside the click handler and do not break the gesture. Apple’s guidance is to “provide a method for the user to grant permission with a gesture” and, when the user completes it, “call the push subscription method immediately from the gesture’s event handler code”. Park the call behind an unrelated await, a route transition or a modal animation and the user activation is gone, so nothing opens and nothing throws.
function urlBase64ToUint8Array(base64) {
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
const raw = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, (char) => char.charCodeAt(0));
}
document.querySelector('#enable-push').addEventListener('click', async () => {
if ((await Notification.requestPermission()) !== 'granted') return;
const registration = await navigator.serviceWorker.register('/sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
await fetch('/api/push-subscriptions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(subscription)
});
});
Store endpoint, keys.p256dh and keys.auth against the user account. The endpoint is the row you will be deleting later. The same user-activation rule governs credential creation, which came up in our write-up on passkeys in Node.
Do you still need a service worker for web push on iOS?
Not since Safari 18.4. Declarative Web Push arrived in iOS 18.4, iPadOS 18.4 and macOS 15.5, and it lets the browser build the notification from JSON with no worker involved at all. Subscribing goes through window.pushManager:
const subscription = await window.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
The payload format is now written into the W3C Push API draft, and the parser is strict enough that it is worth reading before you write the sender:
{
"web_push": 8030,
"notification": {
"title": "Order 4471 dispatched",
"body": "Out for delivery tomorrow before 6pm",
"navigate": "https://example.co.uk/orders/4471",
"lang": "en-GB"
}
}
web_push must be the integer 8030. title and navigate are both required strings, and if either is missing the parser returns failure and the message is silently dropped. The rest mirrors NotificationOptions: body, icon, image, badge, tag, dir, lang, silent, renotify, requireInteraction, timestamp, data and actions, where every action carries its own action, title and navigate.
Add mutable: true and a registered service worker receives a push event holding the proposed notification, so it can swap in its own. Leave it out, which is the default, and the platform draws the notification directly. WebKit also honours an app_badge member for the Home Screen icon count; that member appears in WebKit’s implementation rather than the spec’s member list, so keep navigator.setAppBadge() in the worker for everything else.
Safari is the only engine shipping this as of September 2026. Chromium has an open issue for it and nothing released. Treat it as an enhancement: send the JSON, keep the worker, and let one payload feed both paths.
self.addEventListener('push', (event) => {
const { notification = {} } = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(notification.title ?? 'Update', {
body: notification.body,
data: { url: notification.navigate }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url;
if (url) event.waitUntil(clients.openWindow(url));
});
Which errors will the Apple push endpoint return?
The Node side is still web-push 3.6.7, published in January 2024 and quiet since. It handles VAPID signing and aes128gcm encryption, and it throws a WebPushError carrying statusCode, body and endpoint.
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
export async function send(subscription, notification) {
const payload = JSON.stringify({ web_push: 8030, notification });
try {
await webpush.sendNotification(subscription, payload, { TTL: 3600, urgency: 'normal' });
} catch (error) {
if (error instanceof webpush.WebPushError && error.statusCode === 410) {
await deleteSubscription(error.endpoint);
return;
}
throw error;
}
}
Failures come back as a JSON body with a reason key, and Apple documents every string. PayloadTooLarge means you went over 4 KB, counted after encryption, which is easy to do if you inline an order summary. BadJwtToken covers a missing token, a token signed with the wrong private key, a sub claim that is neither a URL nor a mailto:, an aud claim that is not the origin of the push service you posted to, and an exp more than one day in the future. Cache that token: Apple asks you not to refresh it more than once an hour.
VapidPkHashMismatch is the one that ruins a deploy. It means the public key in the request differs from the key the subscription was created with, so rotating VAPID keys invalidates every subscription you hold. Generate them once, keep them in a secret store, and treat a rotation as a re-subscription campaign.
The rest are quick. BadTtl means a missing or non-positive TTL header, BadUrgency means an Urgency outside very-low, low, normal and high, and a 410 means the token expired, so delete the subscription rather than retrying it. If your egress is locked down, allow https://*.push.apple.com.
What gets your push permission revoked?
Sending a push that shows nothing. Safari has no invisible push, so a data-sync push, a cache warm-up or a heartbeat costs you the permission for the whole origin, and you cannot ask for it again without the user going into Settings. Declarative Web Push sidesteps the problem by design, since the platform always displays something, which is why, as WebKit puts it, “browsers don’t have to apply their ‘silent push penalties’ to Declarative Web Push messages”.
If what you actually want is background data rather than a badge and a banner, push is the wrong transport. While the app is open, a stream is cheaper and simpler, and we compared the options in our look at SSE against WebSockets.
What I would ship
Register a service worker, subscribe with userVisibleOnly: true from a real tap, and send a declarative payload from the server so Safari renders the notification even when the worker has been evicted under storage pressure and Chrome renders the same JSON through showNotification. One payload, two paths, no separate Safari branch in the sender. That is the shape we build on progressive web app projects.
I would not take this route if notifications are the product and the audience is iPhone-heavy. Asking someone to add a web app to their Home Screen before they can be told their order shipped loses most of them at the first step, and a native wrapper with a real App Store listing earns its cost. For a logged-in tool where people already return daily, the Home Screen step is a fair trade and the whole thing costs an afternoon.