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.

API surface in an iOS Safari tab versus an installed web app An iOS Safari tab exposes navigator.serviceWorker but not Notification, PushManager, subscribe or setAppBadge. After the user adds the site to the Home Screen, an installed web app exposes all five. Safari tab on iOS display-mode is browser navigator.serviceWorker yes window.Notification no window.PushManager no pushManager.subscribe() no navigator.setAppBadge() no Add to Home Screen user only Installed web app display-mode is standalone navigator.serviceWorker yes window.Notification yes window.PushManager yes pushManager.subscribe() yes navigator.setAppBadge() yes No API moves a user across this gate — only the Share sheet does. Feature-detect the right-hand column instead of parsing the user agent.
The same origin exposes two different API surfaces on iOS. Only the user, through the Share sheet, can move the session from the left column to the right.

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.

Deciding what to render from the install state Page load runs a capability probe. If PushManager and Notification are present the app can offer an opt-in on a user gesture. If they are absent, check whether a service worker exists and the display mode is not standalone, which indicates an uninstalled iOS tab and calls for an install hint. Otherwise render nothing. Page load run the capability probe PushManager and Notification both present? no yes Cannot subscribe here Worker present, push absent, display mode is browser Push is reachable Wait for a genuine gesture, then requestPermission() Render a dismissible install hint Share sheet, then Add to Home Screen never a modal, never on first visit Subscribe with the VAPID key persist the endpoint server-side then set the app badge if supported
Branch on capability, not on the user agent: three outcomes — offer the opt-in, offer an install hint, or render nothing at all.

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.

Install hint cadence across a visit sequence Across six visits, the install hint stays silent on visits one, two, four and five. It appears inline in the settings row on visit three and contextually after a meaningful action on visit six, then never again. An install hint cadence that does not nag Never on the first visit · at most twice · never modal Inline hint in the settings row dismissible Contextual hint right after a save then never again silent silent hint 1 silent hint 2 silent Visit 1 Visit 2 Visit 3 Visit 4 Visit 5+ Later Record every dismissal in localStorage and stop permanently after the second decline. A hint on every page view reads as a paywall and depresses return visits more than it drives installs.
Two hints across a visit sequence, both inline and both dismissible — the cadence that converts without training users to close your interface.

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.ready both resolve on uninstalled iOS. Only the push interfaces are missing, so guard on PushManager, never on worker availability.
  • iPadOS advertises a desktop user agent. Any iOS detection built on navigator.platform or a iPad regex 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-check getSubscription() on every cold launch and re-subscribe when it returns null.

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.