Why iOS Web Push Requires Add to Home Screen
On iPhone and iPad, pushManager.subscribe() does not exist until the user has added your site to the Home Screen and launched it from that icon. This is a platform gate, not a bug in your code, and no amount of permission handling will work around it.
Quick answer
| Question | Answer |
|---|---|
| Which iOS versions? | iOS and iPadOS 16.4 and later. Earlier versions have no Web Push at all. |
| Does a Safari tab work? | No. WebKit exposes the Push and Notification interfaces only in an installed web app. |
| Can I trigger the install? | No. There is no beforeinstallprompt on WebKit and no API that opens the Share sheet. |
| How do I detect it? | window.matchMedia('(display-mode: standalone)').matches or navigator.standalone === true. |
| What should an uninstalled tab show? | An unobtrusive instruction to use Share → Add to Home Screen — never a permission prompt. |
The service worker itself registers fine in an ordinary tab, which is what makes this confusing to debug: navigator.serviceWorker.register() resolves, navigator.serviceWorker.ready resolves, and then the very next line throws because registration.pushManager is undefined. Registration success is not a signal that push is available. The broader Apple picture — versions, VAPID rules, ignored options — lives in the Safari and iOS Web Push guide.
Two contexts, two API surfaces
WebKit treats an installed web app as a different execution context from a Safari tab, with a different set of exposed interfaces. The same origin, the same HTML, the same service worker script — but the tab gets a strictly smaller API surface. The gate between them is the Share sheet, and only the user can pass through it.
Apple’s reasoning is a permission-economy argument rather than a technical one. A notification permission granted to a Home Screen icon is bound to something the user deliberately placed on their device and can delete with a long press. A permission granted to an arbitrary tab is much easier to forget you gave. Whatever you make of the reasoning, the consequence for your architecture is fixed: on iOS, installation is a prerequisite step in the subscription funnel, and it sits upstream of everything covered in permission prompt timing strategies.
Detecting installed mode correctly
There are two signals, and you should check both. window.navigator.standalone is a non-standard boolean WebKit has exposed since the iOS 4 era; it is true in a Home Screen web app and false (or undefined on non-Apple engines) elsewhere. window.matchMedia('(display-mode: standalone)').matches is the standard equivalent and works across engines, including for Chromium installed apps on Android and desktop.
Neither one alone is sufficient. navigator.standalone is Apple-only, so it tells you nothing about an installed Chromium app. display-mode: standalone returns false for a manifest that declares fullscreen or minimal-ui. Combine them, and treat the presence of the actual APIs as the authoritative check:
// Three independent signals, combined into one state you can render from.
export function getInstallState() {
const modes = ['standalone', 'fullscreen', 'minimal-ui'];
const displayModeInstalled = modes.some(
(mode) => window.matchMedia('(display-mode: ' + mode + ')').matches
);
const iosStandalone = window.navigator.standalone === true;
const installed = displayModeInstalled || iosStandalone;
const hasWorker = 'serviceWorker' in navigator;
const hasPush = 'PushManager' in window;
const hasNotification = 'Notification' in window;
const pushAvailable = hasWorker && hasPush && hasNotification;
if (pushAvailable) return { state: 'ready', installed };
// A worker but no push interfaces, and not installed: the signature of an iOS tab.
if (hasWorker && !installed) return { state: 'needs-install', installed: false };
return { state: 'unsupported', installed };
}
Three states, not two, is the important design decision. unsupported means no amount of user action helps and you should render nothing. needs-install means the user can reach push but has a step to take. ready means you can offer an opt-in control. Collapsing needs-install into unsupported costs you every iOS subscriber; collapsing it into ready produces a button that throws when clicked.
Why user-agent sniffing fails here
The tempting shortcut is a regex against navigator.userAgent for iPhone|iPad|iPod. It breaks in at least four ways that matter in production.
iPadOS reports a desktop Safari user agent by default, so an iPad looks like a Mac and your regex misses it entirely. Every third-party browser on iOS — Chrome, Edge, Firefox, Brave — is WebKit underneath but advertises its own brand token, so a Chromium-shaped user agent on iOS is still subject to the same install gate. In-app browsers such as the ones inside social apps present yet more strings, and several of them cannot install to the Home Screen at all. And user agents change with every OS release, so a regex that works today silently stops matching on a future version.
Feature detection has none of these failure modes because it asks the question you actually care about: is PushManager there or not. The only place a platform hint is legitimately useful is copy — the instruction text differs between iOS Safari’s Share sheet and Chromium’s install menu — and even then, prefer branching on 'onbeforeinstallprompt' in window over the user agent string. The same discipline applies to the rest of your capability gating, as the browser compatibility reference covers per engine.
Manifest fields that make installation possible
iOS will offer Add to Home Screen for almost any page, but whether the result behaves like an app — and therefore whether push works — depends on the manifest. Four fields carry real weight:
| Field | Why it matters on iOS |
|---|---|
display |
Must be standalone, fullscreen or minimal-ui. A browser value produces a bookmark that launches in Safari, with no push. |
scope |
Bounds which URLs stay inside the app. Too narrow and a navigation ejects the user into Safari, losing the push-capable context. |
start_url |
The launch target. Must be inside scope. Add a query parameter to make installed sessions attributable. |
icons |
Read by WebKit since iOS 16.4 for both the Home Screen icon and notification artwork. Ship at least 192×192 and 512×512 PNGs. |
scope is the field teams get wrong most often, and the symptom is indirect: users install successfully, subscribe successfully, then a link to a URL outside the scope drops them into Safari where nothing works. Set scope to / unless you have a strong reason not to, and keep it consistent with your service worker’s registration scope — mismatches produce the failures catalogued in service worker scope errors breaking push subscribe.
Designing the install hint without nagging
Because there is no programmatic prompt, the instruction is pure interface design, and the failure mode is obvious: a full-screen interstitial on every visit that reads like a paywall. It suppresses return visits far more than it drives installs.
A cadence that works treats the hint as a low-priority affordance rather than an interruption. Never show it on the first visit — a user who does not yet know what the product does will not install it. Show it inline, near content the user already cares about, rather than as an overlay. Show it at most twice, and stop permanently after the second dismissal. And show it in context: immediately after the user saves something, follows something or completes a purchase, where the value of a notification is self-evident.
The state machine behind that cadence is small enough to keep in one place:
const HINT_KEY = 'ios-install-hint-v2';
function readHintState() {
try {
return JSON.parse(localStorage.getItem(HINT_KEY)) || { shown: 0, dismissed: 0, visits: 0 };
} catch {
return { shown: 0, dismissed: 0, visits: 0 };
}
}
export function maybeShowInstallHint(installState, { afterMeaningfulAction = false } = {}) {
if (installState.state !== 'needs-install') return false;
const state = readHintState();
state.visits += 1;
localStorage.setItem(HINT_KEY, JSON.stringify(state));
if (state.dismissed >= 2) return false; // Asked twice, declined twice. Done.
if (state.visits < 3 && !afterMeaningfulAction) return false;
if (state.shown >= 2) return false;
state.shown += 1;
localStorage.setItem(HINT_KEY, JSON.stringify(state));
return true;
}
export function recordHintDismissal() {
const state = readHintState();
state.dismissed += 1;
localStorage.setItem(HINT_KEY, JSON.stringify(state));
}
Version the storage key. When you redesign the hint or fundamentally change the product’s notification value, a new key gives previously-declining users one fresh, honest ask instead of permanently excluding them. The soft-ask patterns in designing a push soft-ask bell icon transfer directly to install hints; the only difference is that the user completes the action in the operating system rather than in your interface.
Gotchas and edge cases
- The service worker registers in a tab, which masks the problem. Registration and
serviceWorker.readyboth resolve on uninstalled iOS. Only the push interfaces are missing, so guard onPushManager, never on worker availability. - iPadOS advertises a desktop user agent. Any iOS detection built on
navigator.platformor aiPadregex will silently exclude every iPad. Feature-detect instead. - Deleting the icon deletes the subscription. Removing the Home Screen icon removes the web app’s storage and its push registration; your next send returns
410 Gone. Purge on that status rather than retrying. - Installing twice creates two independent apps. iOS does not deduplicate Home Screen installs, and each one can hold its own subscription for the same user. Deduplicate server-side on the account, not the device.
- In-app browsers often cannot install at all. A link opened inside a social app’s embedded browser may have no Share sheet path to Add to Home Screen. Detect the dead end and offer “open in Safari” first.
- Installed apps can miss
pushsubscriptionchange. If the app is not launched for a long period, endpoint rotation may go unnoticed. Re-checkgetSubscription()on every cold launch and re-subscribe when it returnsnull.
Related
- Declarative Web Push vs service worker push — once installed, the two ways Safari can render your notification and when each applies.
- Browser Compatibility Reference — where the iOS install requirement sits in the wider engine support matrix.
- Service Worker Registration Patterns — scope, lifecycle and the registration that must already be in place inside the installed app.
- Core Protocols & Browser Implementation — the protocol stack this platform gate sits on top of.
Back to Safari & iOS Web Push Integration
FAQ
Can I show the Add to Home Screen sheet programmatically on iOS?
No. WebKit implements no beforeinstallprompt event and exposes no API that opens the Share sheet. The user must tap Share and then Add to Home Screen themselves. All you can do is render clear, well-timed instructions and detect afterwards whether they succeeded.
How do I tell an uninstalled iOS tab apart from a browser that simply lacks push?
Check three things: a service worker is available, PushManager is not, and the display mode is not standalone. That combination is the signature of an uninstalled iOS tab. A desktop browser without push support usually fails the service worker check too, and an installed app passes all three.
Does the user keep their push subscription if they delete the Home Screen icon?
No. Deleting the icon removes the installed web app along with its storage and its push registration. Your next send to that endpoint returns 410 Gone, which you should treat as terminal and purge. If the user reinstalls, they subscribe again as a new record and must grant permission afresh.