Skip to content

Platforms · Platform

PWAs on iOS and Safari

Published Updated

On iOS and iPadOS a PWA is a Home Screen web app: the user adds the site from the Share sheet, and from then on it opens outside the browser with its own App Switcher entry, and can use service workers, Web Push, and badging if the site implements them. There is no programmatic install step, so the platform work is teaching the gesture and designing for what WebKit ships in each release.

Three OS releases define the behaviour a site sees: iOS 16.4 (2023-03) made third-party browsers and manifests part of the flow, iOS 18.4 (2025-03) added Declarative Web Push, and iOS 26 (2025-09) made “open as a web app” the default for every added site.

Apple’s iPhone guide Turn a website into an app in Safari on iPhone documents the path in Safari (English UI): tap the Share button, scroll the list of actions, tap Add to Home Screen, leave Open as Web App turned on, then tap Add. If Add to Home Screen is missing from the sheet, the user restores it through Edit Actions at the bottom of the list. The icon exists only on the device where it was added.

Since iOS and iPadOS 16.4 a third-party browser can offer the same action if, per the WebKit announcement, the app holds the com.apple.developer.web-browser managed entitlement, a WKWebView showing an http: or https: document is in the Share sheet’s activityItems, and the device is not a Shared iPad.

On iOS 16.4 through 18 the manifest decided: a site whose manifest set display to standalone or fullscreen opened as a web app no matter which browser added it, and a site without such a manifest (and without the legacy web-app-capable meta tag) became a Home Screen bookmark that opens in the user’s default browser.

iOS 26 and iPadOS 26 moved the decision to the user. WebKit’s Safari 26.0 notes state that by default every website added to the Home Screen opens as a web app, that the user can disable “Open as Web App” while adding to get a bookmark instead, and that there are “zero requirements for installability” in Safari. A manifest still shapes the result: icons declared in it are used, and display, name, and start_url apply.

Manifest icons have been read since iOS 15.4, but an apple-touch-icon link takes precedence when both are present. With no icon at all, iOS 16.4 and later draw a monogram from the first letter of the site’s name on a colour taken from the site. The manifest id member (16.4+) is combined with the name the user typed at add time to tell multiple installs of one site apart, which is what lets Focus silence one copy and not another.

Web Push, the Notifications API, and the Badging API work in Home Screen web apps from iOS 16.4; a Safari tab on iOS has none of them. Notification permission must be requested in response to a user gesture such as a tap on a subscribe button, and setAppBadge() changes the stored count even before permission is granted, showing it on the icon once notifications are allowed. Declarative Web Push (iOS and iPadOS 18.4, macOS 15.5) adds window.pushManager and displays notifications without a service worker; the original push flow still subscribes through a service worker registration.

Browser-stored data is best-effort unless the origin calls navigator.storage.persist(), and Safari answers that call from the user’s interaction history without showing a prompt. With cross-site tracking prevention on, Safari deletes an origin’s script-created data after seven days of browser use with no tap or click on that origin; cookies set by the server survive (MDN, Storage quotas and eviction criteria). From iOS 17 a Home Screen web app gets the browser-app quota of about 60 % of the disk per origin, not the 15 % an embedded WKWebView gets.

Both snippets run in a Safari tab and in the Home Screen web app; the branches are what differ.

Subscribing to push with the declarative manager first

Section titled “Subscribing to push with the declarative manager first”

Which PushManager the page gets depends on the flavour available. The declarative one hangs off window and needs no service worker; the original one lives on a registration and has to wait for an active worker.

subscribeButton.addEventListener('click', async () => {
if (!('PushManager' in window) || !('Notification' in window)) {
// A Safari tab on iOS, or any browser without push: offer an in-app inbox.
showInAppInbox();
return;
}
let pushManager;
if ('pushManager' in window) {
pushManager = window.pushManager; // Declarative Web Push, iOS 18.4+
} else if ('serviceWorker' in navigator) {
await navigator.serviceWorker.register('/sw.js');
pushManager = (await navigator.serviceWorker.ready).pushManager;
} else {
showInAppInbox();
return;
}
if ((await Notification.requestPermission()) !== 'granted') return;
await pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
});

WebKit rejects a subscription without userVisibleOnly: true, as Chrome and Edge do. The declarative path also requires the declarative JSON payload on the server side; see iOS Safari push.

Showing the install hint only in a browser tab

Section titled “Showing the install hint only in a browser tab”

The display-mode media feature reports the current presentation, not how the page was launched, so test for the one mode an install hint belongs in rather than listing the modes it does not.

const inBrowserTab =
typeof window.matchMedia === 'function' &&
window.matchMedia('(display-mode: browser)').matches;
if (inBrowserTab) {
// No install event exists on iOS: explain Share > Add to Home Screen yourself.
showAddToHomeScreenHint();
} else {
// Home Screen web app, fullscreen, or an engine that cannot answer: no hint.
hideInstallHint();
}

A page that entered fullscreen through the Fullscreen API also stops matching browser, which is the right outcome here: an overlay on top of a fullscreen video is worse than a missed hint.

beforeinstallprompt is listed by MDN with no Safari or Safari on iOS version, so the only install UI is the Share sheet, whose labels changed with iOS 26.

Because the switch is per add, two users can add the same URL and get different behaviour; detect display-mode at runtime instead of assuming the manifest won.

Specifications

SpecificationStatus
None.