Skip to content

PWAs on iOS and iPadOS

On iPhone and iPad, a Progressive Web App becomes a Home Screen web app: the user adds it through the Share sheet, and it launches in its own WebKit-powered window with no browser interface, its own storage, and access to push notifications and badges that Safari tabs don't get. Every browser on iOS uses WebKit, so Safari's rules are the rules for everyone. Since iOS 26, any site added to the Home Screen opens as a web app by default. This page covers how that works, which manifest members and Apple meta tags iOS reads, what's supported and what isn't, storage isolation and quotas, splash screens, the status bar and safe areas, navigation and OAuth, debugging with Web Inspector, and the 2024 EU episode that nearly removed Home Screen web apps.

Key takeaways

  • Installation is manual and has no API. Users tap Share, then Add to Home Screen, with Open as Web App switched on (the default since iOS and iPadOS 26). There's no beforeinstallprompt or appinstalled, so your UI has to explain the steps.
  • Home Screen web apps are separate apps. Each has its own cookies (copied from Safari when it's created, since iOS 17.2), its own storage and its own permissions. WebKit keeps their data "isolated from Safari" and exempts them from Safari's seven-day deletion of script-writable storage.
  • Some features exist only inside the web app. Web Push, the Notification interface and the Badging API (iOS 16.4 and later) are undefined in a Safari tab. Screen Wake Lock works in Home Screen web apps only from iOS 18.4.
  • There's no background execution except push. No Background Sync, Periodic Background Sync or Background Fetch. Sync when the app opens or becomes visible.
  • Safari reads a subset of the manifest: name, short_name, start_url, scope, display, id, theme_color and, when there's no apple-touch-icon, icons. No background_color splash, no orientation, no shortcuts, no share_target. Apple's apple-touch-icon, status bar and startup image tags still matter.
  • Out-of-scope links open in an in-app Safari view. Plan sign-in flows around that, prefer passkeys or first-party sign-in, and test OAuth on a real device.
  • In the EU and Japan, other engines are allowed for browser apps, not for Home Screen web apps. After reversing its iOS 17.4 beta decision, Apple kept Home Screen web apps "built directly on WebKit". As of September 2026 no alternative engine had shipped to users.

How iOS runs a Home Screen web app

A Home Screen web app isn't a Safari tab in a different frame. iOS treats it as an app: it has its own icon, its own entry in the app switcher, its own notification settings and its own website data store. WebKit renders it, as it renders everything on iOS, and it uses the same web platform features as Safari of the same iOS version.

flowchart TD
    U["User taps Share, then Add to Home Screen"] --> T{"Open as Web App on?"}
    T -- "no" --> B["Bookmark icon: opens a Safari tab"]
    T -- "yes (default on iOS 26+)" --> W["Home Screen web app"]
    W --> S["Own website data store: cookies copied once, then separate"]
    W --> P["Own permissions: notifications, camera, location"]
    W --> N["Push, Notification, Badging available"]
    W --> L["Launches start_url, no browser UI"]
    L --> I{"Navigation target in scope?"}
    I -- "yes" --> L2["Stays in the web app"]
    I -- "no" --> V["Opens in an in-app Safari view"]

Things to take from the diagram:

  • The browser that created it doesn't matter. Since iOS and iPadOS 16.4, "third-party browsers can now offer their users the ability to add websites and web apps to the Home Screen" (WebKit). Chrome, Edge and Firefox on iOS all use WebKit, and the web app they create is the same kind of WebKit app Safari creates. It doesn't open in the browser that added it. One difference is undocumented: cookies are copied from Safari when Safari creates the web app, and Apple doesn't document the behavior when another browser creates it.
  • Each install is its own app. WebKit: "iOS has supported multiple installs of the same web app since the very beginning." A user can add your site twice, with different names, for example for work and personal accounts. From iOS 16.4, WebKit combines the name the user chose with the manifest id "to uniquely identify the web app". Each copy has its own storage, permissions and push subscription.
  • Bookmarks aren't web apps. If the user switches off Open as Web App, or on iOS 18 and earlier if your site didn't ask for standalone display, the icon is a bookmark. It opens Safari, and none of the web-app-only features apply.

A timeline of web apps on iOS

Apple promoted web apps as the way to build for the original iPhone, and the apple-mobile-web-app-capable meta tag and Home Screen web clips predate the Web App Manifest by years. Modern PWA support starts with iOS 11.3.

Date Release What changed for web apps
March 29, 2018 iOS 11.3 (Safari 11.1) Service workers and the Cache API, "available in Safari, applications that use SFSafariViewController, and web applications saved to your home screen" (WebKit). Web App Manifest support for name, short_name, start_url, scope and display: standalone. The Cache API quota was then "a fixed value of 50 MiB per partition".
March 25, 2019 iOS 12.2 (Safari 12.1) Web Share API. Home Screen websites now "pause in the background instead of relaunching each time". display-mode media query.
March 24, 2020 iOS 13.4 (Safari 13.1) Intelligent Tracking Prevention's seven-day cap on script-writable storage. Home Screen web apps get "their own counter of days of use".
September 20, 2021 iOS 15 (Safari 15) theme-color meta tag colors "the status bar in iOS". Web Share Level 2 file sharing.
December 13, 2021 iOS 15.2 Origin Private File System, navigator.storage.persist()
March 14, 2022 iOS 15.4 Manifest icons, used "when no apple-touch-icon is provided". Navigation preload, Web Locks.
March 27, 2023 iOS 16.4 Web Push and the Badging API for Home Screen web apps, the manifest id, Add to Home Screen from third-party browsers. Screen Wake Lock in Safari (not yet in web apps). Unprefixed Fullscreen API on iPad.
September 18, 2023 iOS 17 New storage policy: an origin can use up to 60% of disk, navigator.storage.estimate()
December 11, 2023 iOS 17.2 (Safari 17.2) "Copying cookies when saving a website to the Home Screen on iOS and iPadOS". The same Web Apps section lists fixes, without naming a platform, for the scope member not being respected, sign-in pages opening in Safari, and notification taps more than 30 seconds after delivery; several of them concern the then-new Mac web apps.
March 5, 2024 iOS 17.4 EU: alternative browser engines allowed. Home Screen web apps, removed in EU betas, restored before release.
May 13, 2024 iOS 17.5 Fixes for Web Push not showing notifications when the web app wasn't running
September 16, 2024 iOS 18 View Transitions API
March 31, 2025 iOS 18.4 Declarative Web Push, "Fixed Wake Lock API for Home Screen Web Apps", Cookie Store API
September 15, 2025 iOS 26 "Added support for any website to become a web app on iOS or iPadOS." Open as Web App on by default. Theme color used only for installed web apps. WebGPU. Automatic service worker inspection in Web Inspector.
December 12, 2025 iOS 26.2 Navigation API. Japan: alternative browser engines allowed (MSCA). Fix for audio failing when reopening a Home Screen web app.
March 24, 2026 iOS 26.4 WebTransport
September 14, 2026 iOS 27 (Safari 27) Service worker static routing API (InstallEvent.addRoutes()), maxAge in cookieStore.set(). No web-app-specific entries in the release notes.

The takeaway from the timeline is that iOS support arrives in large steps with each x.0 and x.4 release, and that behavior differs markedly between iOS versions still in use. Check the iOS versions in your analytics before relying on anything newer than 16.4.

Adding a web app to the Home Screen

The flow on iOS 26 and iOS 27

Apple's iPhone User Guide (iOS 27 edition) describes the steps:

  1. Open the site in Safari.
  2. Tap the More button (⋯), then Share. With the Bottom or Top tab layout, tap Share directly.
  3. Scroll down the list of options and tap Add to Home Screen. If it isn't listed, tap Edit Actions at the bottom and add it.
  4. Keep Open as Web App switched on. It's on by default.
  5. Tap Add.

Apple adds: "When you tap the icon, the website opens just like an app. You can receive notifications from the web app and quit the web app like you would any app." Other browsers put Add to Home Screen in their own share menus.

During the flow iOS shows the icon, a name field pre-filled from your metadata (the user can edit it), and the Open as Web App switch. When the user taps Add, iOS creates the app, copies Safari's cookies for the site into it (iOS 17.2 and later), and puts the icon on the Home Screen. It doesn't launch the app.

What changed in iOS 26

Before iOS 26, a Home Screen icon opened as a web app only if the site asked for it: a manifest whose display was standalone or fullscreen, or the legacy <meta name="apple-mobile-web-app-capable" content="yes">. Everything else became a bookmark.

WebKit's Safari 26.0 announcement reversed this: "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." And: "There are now zero requirements for 'installability' in Safari." The same post explains that a manifest is still worth having, "If you include a Web Application Manifest with your site, the benefits it provides will be part of the user's experience", and that "Home Screen web apps on iOS and iPadOS never required Service Workers (as PWAs do on other platforms), yet including Service Workers in your code can greatly enhance the user experience."

Consequences for you:

  • Any site can end up as a standalone app, including pages you never designed for it. There's no back button, reload button or address bar in a web app (see Navigation, gestures and links). Test your site as a web app even if you never intended it to be one.
  • The manifest customizes, it doesn't gate. Name, icons, start_url, scope, id and theme color still come from your markup.
  • The user decides, not the manifest. A user can create a bookmark for a site that declares standalone. Detect the context at runtime instead of assuming.

Guiding users to install

Because there's no install event, the best you can do is show instructions at the right moment: after the user has seen value, and only in an iOS or iPadOS browser tab. The non-standard navigator.standalone property is false in a browser tab and true in a Home Screen web app. It used to exist only on iOS, and many snippets still treat its presence as "this is an iPhone or iPad". That stopped being true in 2023: WebKit enabled it "for all Cocoa platforms, which including macOS and iOS" (265004@main), so Safari 17 and later on macOS exposes it as well. Macs have no touchscreen, so combining it with navigator.maxTouchPoints > 0 separates iPhone and iPad (whose Safari reports a macOS user agent by default) from Mac Safari without parsing the user agent. The user agent is only used to pick the right wording for Chrome, Edge or Firefox on iOS, whose share buttons are in different places.

ios-install-hint.js
// Shows Add to Home Screen instructions in iOS/iPadOS browser tabs.
// navigator.standalone: false in a tab, true in a web app. WebKit also exposes it
// on macOS (Safari 17+), so maxTouchPoints rules out Macs, which have no touchscreen.

const DISMISS_KEY = "ios-install-hint-dismissed-at";
const DISMISS_DAYS = 14;

function isAppleMobileTab() {
  return (
    "standalone" in navigator &&
    navigator.standalone === false &&
    navigator.maxTouchPoints > 0 // iPhone and iPad; excludes macOS Safari tabs
  );
}

function recentlyDismissed() {
  try {
    const at = Number(localStorage.getItem(DISMISS_KEY));
    return Number.isFinite(at) && Date.now() - at < DISMISS_DAYS * 86_400_000;
  } catch {
    return false; // storage blocked: behave as if never dismissed
  }
}

// Only used for wording. Feature decisions never depend on the UA string.
function shareButtonHint() {
  const ua = navigator.userAgent;
  if (/CriOS\//.test(ua)) return "Tap Share in Chrome's address bar or menu";
  if (/EdgiOS\//.test(ua)) return "Open Edge's menu and tap Share";
  if (/FxiOS\//.test(ua)) return "Open Firefox's menu and tap Share";
  return "Tap Share (in Safari, it may be under the ⋯ button)";
}

export function initInstallHint(container) {
  if (!container || !isAppleMobileTab() || recentlyDismissed()) return;

  container.querySelector("[data-step='share']").textContent = shareButtonHint();
  container.hidden = false;

  container.querySelector("[data-action='dismiss']").addEventListener("click", () => {
    container.hidden = true;
    try {
      localStorage.setItem(DISMISS_KEY, String(Date.now()));
    } catch {
      /* ignore: the hint may reappear next visit */
    }
  });
}
index.html (excerpt)
<aside id="install-hint" hidden aria-labelledby="install-hint-title">
  <h2 id="install-hint-title">Install this app</h2>
  <ol>
    <li data-step="share">Tap Share</li>
    <li>Choose <strong>Add to Home Screen</strong></li>
    <li>Keep <strong>Open as Web App</strong> switched on, then tap <strong>Add</strong></li>
  </ol>
  <button type="button" data-action="dismiss">Not now</button>
</aside>
<script type="module">
  import { initInstallHint } from "/ios-install-hint.js";
  initInstallHint(document.querySelector("#install-hint"));
</script>

Trigger the hint from a meaningful moment, for example when a user tries to turn on notifications (which needs the web app on iOS) or saves something for offline use, rather than on first load. Install Prompts & Custom UI covers prompt timing across browsers, and Detecting Installed Apps covers measuring installs.

Measure installs without an event

iOS never fires appinstalled. Count installs by recording the first launch where navigator.standalone === true (or display-mode: standalone matches) per device, for example with a flag in IndexedDB inside the web app's own storage. Because the web app's storage is separate from Safari's, the flag is naturally per install.

What Safari reads from your manifest

Safari parses the manifest linked with <link rel="manifest"> when the user opens the Add to Home Screen sheet. According to MDN's compatibility data for Safari on iOS, as of September 2026:

Member Used on iOS / iPadOS Since Notes
name ✅ 11.3 Pre-fills the name field. Users can edit it.
short_name ✅ 11.3
start_url ✅ 11.3 The URL the web app loads when launched
scope ✅ 11.3 Navigations outside it open in an in-app Safari view. Safari 17.2's release notes list a fix for "the scope member in the web app manifest is not respected" (the Web Apps notes don't say which platform it applied to).
display ⚠️ 11.3 standalone supported. MDN lists fullscreen and minimal-ui as unsupported; in practice a fullscreen manifest opens as standalone. On iOS 26 and later, the user's Open as Web App choice decides app versus bookmark.
id ✅ 16.4 Identifies the app together with the user-chosen name. Used to sync Focus settings.
icons ⚠️ 15.4 Only when there's no apple-touch-icon link, and only icons whose purpose is any or absent
theme_color ✅ 15
background_color ❌ – No generated splash screen. Use startup images.
orientation ❌ – The app follows the device's rotation.
shortcuts ❌ – Supported only in Safari on macOS (17.4)
share_target, file_handlers, protocol_handlers, launch_handler ❌ – Chromium-only integrations
display_override ❌ –
description, screenshots ❌ – No rich install UI

Safari reads the manifest when the app is created. Apple doesn't document any mechanism that updates an existing Home Screen web app from a changed manifest, the way Chromium re-checks manifests and re-mints WebAPKs. Assume that a new name, icon or start_url reaches only new installs, and that existing users get it only by removing and re-adding the app. Plan identity carefully before launch: App Identity & Updates explains how to choose an id that never needs to change.

Apple's own tags predate the manifest and still matter, partly because iOS prefers them (icons) and partly because they cover things the manifest can't express on iOS (status bar, startup images). Apple documents them in its archived Safari Web Content Guide and Safari HTML Reference.

Tag Purpose Status in 2026
<link rel="apple-touch-icon" href="…"> The Home Screen icon Current. Takes precedence over manifest icons.
<meta name="apple-mobile-web-app-title" content="…"> Default name in the Add to Home Screen sheet. Apple: "By default, the <title> tag is used." Current
<meta name="apple-mobile-web-app-capable" content="yes"> Legacy opt-in to standalone mode Unnecessary on iOS 26 and later, where every added site is a web app by default. Harmless to keep for older iOS versions if you have no manifest.
<meta name="apple-mobile-web-app-status-bar-style" content="…"> Status bar appearance in the web app default, black, black-translucent. Apple: "This meta tag has no effect unless you first specify full-screen mode."
<link rel="apple-touch-startup-image" href="…" media="…"> Launch image shown while the app loads Apple-specific. See Splash screens on iOS.
<meta name="theme-color" content="…" media="…"> Status bar and UI tint Since Safari 15. MDN: "From Safari on iOS 26, the theme color is only used for installed web apps."
<meta name="format-detection" content="telephone=no"> Stops Safari turning phone-number-like text into links Apple extension. Useful in data-heavy apps.

Icons: apple-touch-icon first

When a page has an apple-touch-icon link, iOS uses it and ignores the manifest's icons. Without one, iOS 15.4 and later uses a manifest icon whose purpose is any or missing. Apple's guide lists 180 × 180 (iPhone), 167 × 167 (iPad Pro) and 152 × 152 (iPad) sizes, and "the icon that is the most appropriate size for the device is used". iOS applies its own rounded-corner mask and doesn't honor maskable, so provide an opaque, square, full-bleed image with no rounded corners and no transparency. Transparent pixels render against a background you don't control.

Don't confuse this with the Mac. Safari 17.2's release notes ask Mac web apps for "at least one opaque, full-bleed maskable square icon in the web app manifest, either as an SVG (any size) or high resolution bitmap (1024x1024)", and say Safari "may fall back to the apple-touch-icon" there. The same notes warn: "do not omit these link elements based on user agent detection." Serve the apple-touch-icon link to every browser and a full-bleed maskable icon in the manifest, and both platforms get a good icon.

If you provide no usable icon at all, iOS falls back to a generated placeholder that rarely looks intentional. Icons & Maskable Icons covers sizes and the full precedence rules.

A complete <head> for iOS

index.html (head)
<head>
  <meta charset="utf-8">
  <!-- viewport-fit=cover lets content extend under the status bar and home indicator. -->
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Field Notes</title>

  <!-- Standard manifest: name, start_url, scope, id, display, theme_color, icons. -->
  <link rel="manifest" href="/manifest.webmanifest">

  <!-- iOS prefers this over manifest icons: 180x180, opaque, square, no rounded corners. -->
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">

  <!-- Name suggested in the Add to Home Screen sheet (user can edit it). -->
  <meta name="apple-mobile-web-app-title" content="Field Notes">

  <!-- Status bar over content; pair with safe-area padding in CSS. -->
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

  <!-- Theme color per scheme. On iOS 26+ used only by installed web apps. -->
  <meta name="theme-color" content="#f7f7f5" media="(prefers-color-scheme: light)">
  <meta name="theme-color" content="#161616" media="(prefers-color-scheme: dark)">

  <!-- Legacy standalone switch for iOS 18 and earlier; unnecessary on iOS 26+. -->
  <meta name="apple-mobile-web-app-capable" content="yes">
  <!-- Chromium's equivalent of the tag above. -->
  <meta name="mobile-web-app-capable" content="yes">

  <!-- Startup images: see "Splash screens on iOS" below. -->
</head>
manifest.webmanifest
{
  "id": "/",
  "name": "Field Notes",
  "short_name": "Notes",
  "start_url": "/?source=homescreen",
  "scope": "/",
  "display": "standalone",
  "theme_color": "#f7f7f5",
  "background_color": "#f7f7f5",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

background_color and the maskable icon do nothing on iOS, but they're used by Chromium and Firefox for Android, so keep them. The Members Reference documents every member.

Features available to Home Screen web apps

The table compares a Safari tab with a Home Screen web app on the same device, as of September 2026, using MDN's compatibility data and Apple's release notes. "Since" is the iOS version. All iOS browsers share these results.

Feature Safari tab Home Screen web app Since Notes
Service workers, Cache API ✅ ✅ 11.3 Not in WKWebView in-app browsers, per MDN, except apps that opt in to App-Bound Domains (iOS 14+)
IndexedDB ✅ ✅ – Separate database per context (see Storage)
Origin Private File System ✅ ✅ 15.2 Including createSyncAccessHandle() in workers
navigator.storage.persist() ✅ ✅ 15.2 Heuristic grant, favoring Home Screen web apps
navigator.storage.estimate() ✅ ✅ 17
Navigation preload ✅ ✅ 15.4
Static routing (addRoutes()) ✅ ✅ 27
Web Push, PushManager ❌ ✅ 16.4 Requires a user gesture to prompt. See Web Push on iOS & Safari.
Notification interface ❌ (undefined) ✅ 16.4 Show notifications with registration.showNotification(); MDN notes the constructor throws.
Declarative Web Push, window.pushManager ❌ ✅ 18.4
Badging API ❌ ✅ 16.4 Displays only after notification permission is granted
Screen Wake Lock ✅ ✅ 16.4 tab, 18.4 web app
Web Share (navigator.share()) ✅ ✅ 12.2 File sharing and canShare(): MDN lists 14, where it was unreliable; dependable from 15
Payment Request ✅ ✅ 11.3 Apple Pay
WebAuthn and passkeys ✅ ✅ 13
Media Session ✅ ✅ 15 Lock Screen and Control Center media controls
Picture-in-Picture (video) ✅ ✅ 13.4
Geolocation, camera, microphone ✅ ✅ – Separate permission prompts per context
Clipboard writeText() ✅ ✅ 13.4 Only inside a user gesture handler
DeviceOrientationEvent.requestPermission() ✅ ✅ 14.5 Motion data needs explicit permission
Web Locks ✅ ✅ 15.4
Cookie Store API ✅ ✅ 18.4 maxAge from 27
View Transitions ✅ ✅ 18
Navigation API ✅ ✅ 26.2 Useful for in-app back buttons
WebGPU ✅ ✅ 26
WebTransport ✅ ✅ 26.4
Fullscreen API ⚠️ iPad only ⚠️ iPad only 16.4 MDN: "Only available on iPad, not on iPhone."

Two features in that list depend on context in ways that break naive detection:

  • Push and notifications. In a Safari tab on iOS, "Notification" in window is false, and PushManager isn't usable. Inside the web app both exist. If your UI offers "Turn on notifications" in a tab, the button can't work. Show the install hint instead.
  • Wake Lock. Between iOS 16.4 and 18.3, navigator.wakeLock existed inside Home Screen web apps, but MDN notes it "does not work in standalone Home Screen Web Apps". Users on those versions don't get a working lock even though detection succeeds.

What iOS doesn't support, and what to do instead

Many PWA capabilities that exist in Chromium have no WebKit implementation. The table lists the ones PWA developers most often reach for, with the pattern that replaces each on iOS.

Missing on iOS What it does elsewhere What to do on iOS
beforeinstallprompt, appinstalled Custom install button, install analytics Contextual instructions (see above); count first standalone launches
Background Sync Retries queued requests after connectivity returns, even with the app closed Queue in IndexedDB; flush on launch, visibilitychange, online and after each successful request
Periodic Background Sync Refreshes content in the background Refresh on launch and on visibilitychange; use push to tell the app new content exists
Background Fetch Large downloads that survive closing the app Foreground downloads with progress UI, chunked and resumable with Range requests
pushsubscriptionchange Tells the worker a subscription changed Re-check pushManager.getSubscription() on every launch
Notification actions Buttons on notifications Open a page that offers the actions
share_target Appear in the OS share sheet Accept pasted text, file inputs and drag and drop
file_handlers, protocol_handlers Open files and custom schemes <input type="file">; ordinary https: links
shortcuts Long-press menu on the icon In-app navigation
background_color splash Generated launch screen apple-touch-startup-image
orientation, screen.orientation.lock() Lock orientation Responsive layout for both orientations. screen.orientation is readable since 16.4 but lock() isn't supported.
Vibration API Haptic feedback Visual and audio feedback
Web Bluetooth, WebUSB, Web Serial, WebHID, Web NFC Hardware access A native companion app, or leave the feature out
File System Access pickers Open and save files in place File input for opening; downloads or Web Share with files for saving
requestIdleCallback Idle-time scheduling Chunk work with setTimeout or requestAnimationFrame
Link capturing, launch_handler Links open in the installed app None: links from other apps open in the browser

Replacing Background Sync with sync on resume

The only moments a Home Screen web app can run code are while it's in the foreground and while its service worker handles a push event. So instead of registering a sync, flush your outbox whenever the app becomes active. The module below uses Background Sync where it exists and falls back to resume-driven flushing everywhere else, with one code path for both.

outbox.js
// Queue writes in IndexedDB and deliver them when possible.
// Uses Background Sync on Chromium; flushes on resume/online elsewhere (iOS, Firefox, Safari).

const DB_NAME = "outbox-db";
const STORE = "requests";
const SYNC_TAG = "outbox";

function openDb() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);
    request.onupgradeneeded = () => {
      request.result.createObjectStore(STORE, { keyPath: "id", autoIncrement: true });
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function withStore(mode, fn) {
  const db = await openDb();
  try {
    return await new Promise((resolve, reject) => {
      const tx = db.transaction(STORE, mode);
      const result = fn(tx.objectStore(STORE));
      tx.oncomplete = () => resolve(result?.result ?? result);
      tx.onerror = () => reject(tx.error);
      tx.onabort = () => reject(tx.error ?? new DOMException("Aborted", "AbortError"));
    });
  } finally {
    db.close();
  }
}

export async function enqueue(url, body) {
  await withStore("readwrite", (store) =>
    // The key is generated once, so every retry carries the same Idempotency-Key.
    store.add({ url, body: JSON.stringify(body), key: crypto.randomUUID(), queuedAt: Date.now() }),
  );
  await requestDelivery();
}

async function requestDelivery() {
  // getRegistration() resolves undefined when no worker is registered;
  // serviceWorker.ready would never settle in that case.
  const registration = await navigator.serviceWorker?.getRegistration();
  if (registration?.active && "sync" in registration) {
    try {
      await registration.sync.register(SYNC_TAG); // Chromium: the worker flushes
      return;
    } catch {
      // Permission or policy refused: fall through to a foreground flush.
    }
  }
  await flush();
}

const RETRYABLE_4XX = new Set([408, 425, 429]);
let flushing = null;

// Safe to call often: concurrent calls share one run.
export function flush() {
  flushing ??= (async () => {
    try {
      const items = await withStore("readonly", (store) => store.getAll());
      for (const item of items) {
        let response;
        try {
          response = await fetch(item.url, {
            method: "POST",
            headers: { "Content-Type": "application/json", "Idempotency-Key": item.key },
            body: item.body,
            credentials: "same-origin",
          });
        } catch {
          return; // offline again: keep the rest queued
        }
        const retryable = response.status >= 500 || RETRYABLE_4XX.has(response.status);
        if (retryable) return; // 5xx, 408, 425, 429: retry on the next trigger
        // Delivered (2xx) or permanently rejected (other 4xx): remove it either way.
        // Report rejected items to the user in a real app instead of dropping them silently.
        await withStore("readwrite", (store) => store.delete(item.id));
      }
    } finally {
      flushing = null;
    }
  })();
  return flushing;
}

// Resume triggers. On iOS these are the only opportunities to run.
// IndexedDB can fail (quota, storage cleared), so never leave a rejection unhandled.
const safeFlush = () => flush().catch((error) => console.warn("Outbox flush failed", error));
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") safeFlush();
});
window.addEventListener("online", safeFlush);
window.addEventListener("pageshow", safeFlush); // includes back/forward cache restores
safeFlush(); // app launch

The Idempotency-Key header lets the server discard duplicates when a request succeeded but the response never arrived, which happens regularly on mobile networks. Offline-First Data & Sync covers conflict handling, and Background Sync the Chromium side.

Lifecycle: launch, background and termination

A Home Screen web app follows the iOS app lifecycle, not the browser's tab lifecycle:

  • Launch. Tapping the icon loads start_url in a fresh web view if the app isn't running. If it's suspended in the background, iOS resumes it where it was. Since iOS 12.2, Home Screen websites "pause in the background instead of relaunching each time".
  • Background. When the user switches away, the page receives visibilitychange (to hidden) and is suspended shortly after. Don't expect timers, animation frames or in-flight requests to keep running, and there's no web API that asks iOS for background execution time.
  • Termination. Like any suspended app, a web app can be terminated by iOS to reclaim memory, and the user can quit it from the app switcher. The next launch starts from start_url again. Don't count on beforeunload, unload or even pagehide firing before a termination that happens while the app is suspended.
  • Push wake-ups. A push message starts the service worker, which must show a notification. It doesn't start the page.

The practical rule: save state when you become hidden, restore it on launch.

state-restore.js
// Persist UI state when the app is hidden; restore it on the next cold launch.
// iOS may terminate a suspended web app without any further event.

const STATE_KEY = "ui-state-v1";

export function saveState(state) {
  try {
    sessionStorage.setItem(STATE_KEY, JSON.stringify(state)); // survives resume
    localStorage.setItem(STATE_KEY, JSON.stringify({ ...state, savedAt: Date.now() }));
  } catch {
    /* quota or private mode: state is a convenience, not data */
  }
}

export function restoreState(maxAgeMs = 24 * 60 * 60 * 1000) {
  try {
    const raw = sessionStorage.getItem(STATE_KEY) ?? localStorage.getItem(STATE_KEY);
    if (!raw) return null;
    const state = JSON.parse(raw);
    if (state.savedAt && Date.now() - state.savedAt > maxAgeMs) return null;
    return state;
  } catch {
    return null;
  }
}

export function autoSave(getState) {
  // visibilitychange is the last event you can count on before suspension.
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "hidden") saveState(getState());
  });
  window.addEventListener("pagehide", () => saveState(getState()));
}

Use it for UI state (current route, scroll position, form drafts). Keep user data in IndexedDB and on the server, not in localStorage. On a cold launch, start_url loads first, so read the saved route and navigate to it only if it's recent. After a day, starting at the home screen of your app is usually less confusing.

Service workers on iOS follow the same event model as elsewhere (see Lifecycle), with one practical difference: they only run while a page is in the foreground or during a push event. An update check (registration.update()) therefore happens when the user opens the app. Show an in-app "Update available" message or apply updates on the next launch, as described in Updating Service Workers.

Storage: isolation, quotas and eviction

Storage is where Home Screen web apps differ most from Safari tabs, and mostly in your favor.

Separate from Safari

WebKit's tracking prevention documentation says it plainly: "the website data of home screen web applications is kept isolated from Safari". For the same origin, the Safari tab and the Home Screen web app have different cookies, localStorage, sessionStorage, IndexedDB databases, Cache Storage, OPFS, service worker registrations and permissions. Each additional copy of the web app on the same device is isolated in the same way.

The one bridge is at creation time: iOS 17.2 "added support for copying cookies when saving a website to the Home Screen on iOS and iPadOS". A user who was signed in with a cookie session in Safari stays signed in when they first open the web app. After that, the two cookie jars evolve independently: signing out in one doesn't sign out the other. Nothing else is copied. Apple's WWDC23 session on web apps, describing the same design on the Mac, warns that "some websites split authentication state between cookies and local storage. Since local storage is not copied when a web app is created, users would have to re-authenticate". If your client keeps an access token in localStorage or IndexedDB, the first launch of the web app looks signed out even though the cookie arrived. Either keep sessions in HTTP-only cookies, or have the app exchange its cookie for a fresh token on first launch.

Quotas

WebKit's storage policy, in effect since iOS 17, iPadOS 17 and Safari 17:

Limit Browser apps (Safari, and browsers that can be the default, such as Chrome, Edge and Firefox for iOS) Home Screen web apps Other apps (WKWebView)
Origin quota Up to 60% of total disk Same as browser apps Up to 15%
Overall quota (all origins) Up to 80% of total disk Same as browser apps Up to 20%
Cross-origin iframe 10% of the main frame origin's quota Same Same

WebKit: "When a web app is running standalone (as Home Screen Web App on iOS or Web App added to dock on macOS), it has the same origin quota and overall quota as when it is opened in a browser app." With this policy, "Safari 17.0 no longer prompts users about a website wanting to use more space". Exceeding the origin quota throws QuotaExceededError. navigator.storage.estimate() reports the origin quota, but WebKit warns that "the quota is an upper limit of how much can be stored — there is no guarantee that a site can store that much", and that the reported value may change "based on factors like existing usage and site visit frequency" to limit fingerprinting.

The older numbers you'll still find in blog posts (a 50 MiB Cache API limit, prompts at 1 GB) date from iOS 11.3 to iOS 16 and no longer apply.

Eviction and the seven-day cap

WebKit evicts website data in three situations: "when exceeding the overall quota, when the system is under storage pressure, or when the site has not been interacted with by the user for some time". Eviction is per origin, in least-recently-used order, and an origin "might be excluded from eviction if it has active page at the time of eviction, or its storage is in persistent mode".

The third condition is Intelligent Tracking Prevention's seven-day cap, which deletes "all of a website's script-writable storage after seven days of Safari use without user interaction on the site": IndexedDB, localStorage, media keys, sessionStorage, and service worker registrations and caches. For Home Screen web apps, WebKit's 2020 announcement is explicit:

Web applications added to the home screen are not part of Safari and thus have their own counter of days of use. Their days of use will match actual use of the web application which resets the timer. We do not expect the first-party in such a web application to have its website data deleted.

WebKit's tracking prevention page adds: "The first-party domain of home screen web applications is exempt from ITP's 7-day cap on all script-writeable storage". That exemption is the strongest technical argument for asking iOS users to install an offline-first app. It doesn't protect against the other two eviction triggers, and it doesn't protect data in Safari tabs, so the rule stands: never keep the only copy of user data on the device. Storage Quotas & Persistence covers the cross-browser model in depth.

Persistence

navigator.storage.persist() has existed since iOS 15.2, and since iOS 17 WebKit grants it without a prompt, "based on heuristics like whether the website is opened as a Home Screen Web App". Request it inside the web app, where it's most likely to succeed, and record the result:

storage-health.js
// Report storage state and request persistence where it's likely to be granted.
// Safari grants persist() heuristically (Home Screen web apps are favored); it never prompts.

export async function checkStorageHealth() {
  const report = { standalone: navigator.standalone === true, persisted: null, usage: null, quota: null };

  if (!navigator.storage) return report;

  try {
    report.persisted = await navigator.storage.persisted();
    if (!report.persisted && report.standalone && navigator.storage.persist) {
      report.persisted = await navigator.storage.persist();
    }
  } catch (error) {
    console.warn("persist() failed", error);
  }

  try {
    if (navigator.storage.estimate) {
      const { usage, quota } = await navigator.storage.estimate();
      report.usage = usage;
      report.quota = quota;
    }
  } catch (error) {
    console.warn("estimate() failed", error);
  }

  return report;
}

// Wrap writes that can hit the quota so the UI can react instead of failing silently.
export async function putWithQuotaHandling(cacheName, request, response) {
  try {
    const cache = await caches.open(cacheName);
    await cache.put(request, response);
    return true;
  } catch (error) {
    if (error?.name === "QuotaExceededError") {
      // Use this only for expendable runtime caches: clearing it frees space so the
      // next write can succeed. Never point it at your precache or user data.
      await caches.delete(cacheName);
      return false;
    }
    throw error;
  }
}

Send the report to your analytics once per install. It tells you how often persistence is granted in practice and how close heavy users come to the quota.

Clearing data during development

Because the web app's data is separate, clearing Safari's website data (Settings > Apps > Safari > Advanced > Website Data) doesn't necessarily reset your installed app. The dependable reset is to delete the Home Screen icon and add the site again, which gives you a fresh, empty data store. From Web Inspector you can also clear storage for the inspected app in the Storage tab.

Splash screens on iOS

Chromium generates a splash screen from name, background_color and an icon. Safari doesn't use background_color, so iOS has no generated splash screen. Apple's mechanism is the apple-touch-startup-image link. Apple's archived guide documents the basic form, <link rel="apple-touch-startup-image" href="/launch.png">, and notes that "by default, a screenshot of the web application the last time it was launched is used". Apple doesn't document the modern selection rules, but the widely used approach, which tools such as pwa-asset-generator automate, is one image per device size and orientation, each selected with a media query on the device's CSS dimensions and pixel ratio:

index.html (head, excerpt)
<!-- iPhone 15 / 16 class (393x852 pt @3x): 1179x2556 px, portrait -->
<link rel="apple-touch-startup-image"
      href="/splash/apple-splash-1179-2556.png"
      media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">

<!-- iPhone SE 2nd/3rd gen (375x667 pt @2x): 750x1334 px, portrait -->
<link rel="apple-touch-startup-image"
      href="/splash/apple-splash-750-1334.png"
      media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">

<!-- iPad Pro 12.9" (1024x1366 pt @2x): 2048x2732 px, portrait and landscape -->
<link rel="apple-touch-startup-image"
      href="/splash/apple-splash-2048-2732.png"
      media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
<link rel="apple-touch-startup-image"
      href="/splash/apple-splash-2732-2048.png"
      media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">

Writing these by hand for every iPhone and iPad is error-prone, and every new device size needs a new entry. Generate them:

Terminal
# Generates splash images for Apple's device list and prints the <link> tags.
# --dark-mode adds (prefers-color-scheme: dark) to the media queries for a dark set.
npx pwa-asset-generator ./brand/logo.svg ./public/splash --splash-only
npx pwa-asset-generator ./brand/logo-dark.svg ./public/splash --splash-only --dark-mode

Guidelines that hold regardless of tooling:

  • Keep the design minimal. A centered logo on your background color, matching the first frame of your app shell, so the switch from image to page isn't jarring.
  • Match your app shell's colors in both schemes. If the app follows the system dark mode, provide a dark set.
  • Don't cache-bust the URLs on every deploy. iOS fetches these images when the app is added. New file names don't help existing installs.
  • Budget the bytes. There are dozens of variants. They're only fetched by iOS devices, but they add up in your build output and precache lists. Don't precache them in your service worker.
  • Devices you don't cover fall back to iOS's default behavior. Fast first paint from a cached app shell (App Shell Model) matters more than a pixel-perfect launch image.

Splash Screens & Theming covers splash screens on every platform.

Status bar, safe areas and viewport-fit=cover

A Home Screen web app always shows the iOS status bar (time, battery, signal). You choose how your content relates to it.

Status bar styles

apple-mobile-web-app-status-bar-style Status bar Where your content starts Use when
default (or absent) Normal status bar Below the status bar Simple apps; the status bar area is tinted by theme-color
black Black background Below the status bar Dark apps that want a solid bar
black-translucent Translucent, over your content At the very top of the screen, "partially obscured by the status bar" Edge-to-edge designs; requires safe-area padding

Apple's reference describes these three values and says the tag has no effect outside the standalone (web app) mode. With default, the theme-color meta tag (Safari 15 and later) tints the status bar area. theme-color supports a media attribute, so you can provide separate light and dark values, and from iOS 26 Safari applies it only to installed web apps, not to tabs. Status bar text color follows the system appearance and the color underneath. Check contrast in both light and dark mode on a device.

Safe areas

Modern iPhones have rounded corners, a Dynamic Island or notch, and a home indicator. WebKit introduced two tools for this with iPhone X in 2017:

  • viewport-fit in the viewport meta tag. "The default value of viewport-fit is auto, which results in the automatic insetting behavior." cover disables the automatic insets and lets your page fill the screen.
  • env(safe-area-inset-top | right | bottom | left), CSS environment variables giving the distances from each edge that are safe from hardware and system UI. They "shipped in iOS 11 with the name constant()", which was replaced by env() from iOS 11.2. You can ignore constant() today.

With black-translucent or any edge-to-edge layout, use both:

safe-areas.css
/* Requires <meta name="viewport" content="..., viewport-fit=cover"> */

:root {
  --header-h: 56px;
  --tabbar-h: 56px;
}

.app-header {
  position: sticky;
  top: 0;
  /* Extend the header's background under the status bar, keep its content below it. */
  padding-top: env(safe-area-inset-top);
  padding-left: max(16px, env(safe-area-inset-left));
  padding-right: max(16px, env(safe-area-inset-right));
  min-height: calc(var(--header-h) + env(safe-area-inset-top));
  background: var(--surface);
}

.tab-bar {
  position: fixed;
  inset: auto 0 0 0;
  /* Keep tap targets above the home indicator. */
  padding-bottom: env(safe-area-inset-bottom);
  height: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
  background: var(--surface);
}

main {
  padding-left: max(16px, env(safe-area-inset-left));
  padding-right: max(16px, env(safe-area-inset-right));
  padding-bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 16px);
}

/* A full-height layout that tracks the dynamic viewport (iOS 15.4+). */
.app-root {
  min-height: 100vh; /* fallback */
  min-height: 100dvh;
}

The insets change with orientation. In landscape, left and right hold the notch or Dynamic Island area, which is why the horizontal padding uses max() with a minimum. Test both orientations on a device with a notch or Dynamic Island. Responsive & Adaptive Design covers viewport units, and Display Modes compares theme and safe-area behavior across platforms.

A Home Screen web app has no address bar, no back or forward button, no reload button, no share button and no way to see the current URL. iOS has no system back button either. Everything the browser normally provides is your responsibility.

Give users a way back

Every screen deeper than the top level needs a visible back affordance. The Navigation API (iOS 26.2 and later) tells you whether there's somewhere to go back to, which the History API can't:

back-button.js
// Shows a back button only in standalone mode and only when going back stays in the app.

function isStandalone() {
  return navigator.standalone === true || matchMedia("(display-mode: standalone)").matches;
}

export function initBackButton(button, fallbackUrl = "/") {
  if (!isStandalone()) return; // browsers already have a back button

  const canGoBack = () =>
    "navigation" in window ? navigation.canGoBack : history.length > 1;

  const render = () => {
    const atRoot = location.pathname === new URL(fallbackUrl, location.href).pathname;
    button.hidden = atRoot && !canGoBack();
  };

  button.addEventListener("click", () => {
    if (canGoBack()) {
      history.back();
    } else {
      // Cold launch onto a deep link: there's no history entry to return to.
      location.assign(fallbackUrl);
    }
  });

  if ("navigation" in window) {
    navigation.addEventListener("currententrychange", render);
  } else {
    window.addEventListener("popstate", render);
  }
  render();
}

Also provide a refresh path. Without a reload button, a user stuck on stale content has no way out except quitting the app. Offer a pull-down or explicit Refresh action that re-fetches data (not necessarily location.reload()), and surface service worker updates in your UI.

Gestures

Apple doesn't document which system gestures are available inside Home Screen web apps, so design as if none of the browser's gestures exist: don't rely on edge swipes for back navigation or on pull-to-refresh for reloading. What you control:

  • overscroll-behavior (iOS 16 and later) stops scroll chaining from inner scroll containers to the page, which is essential for app-like panels and bottom sheets.
  • touch-action: manipulation on interactive elements removes the double-tap-to-zoom delay and gesture, while keeping pinch zoom elsewhere.
  • -webkit-touch-callout: none suppresses the long-press preview on links and images where it gets in the way (for example, on a custom draggable list). Use it sparingly, because it also removes useful actions.
  • -webkit-tap-highlight-color: transparent removes the gray tap flash when you provide your own :active styles.

Don't disable pinch zoom with user-scalable=no or maximum-scale=1. It's an accessibility failure (Accessibility), and a common reason it's added, inputs zooming on focus, is better fixed by using a font size of at least 16 px for form controls.

flowchart TD
    A["Link or navigation"] --> B{"Where does it start?"}
    B -- "Inside the web app" --> C{"Target in manifest scope?"}
    C -- "yes" --> D["Loads in the web app"]
    C -- "no" --> E["Opens in an in-app Safari view"]
    B -- "Another app (Messages, Mail, Notes)" --> F["Opens in the default browser, never the web app"]
    B -- "Notification tap" --> G["Opens the web app via clients.openWindow or focus"]
  • In-scope navigations stay in the app. Apple's WWDC23 session: "Links within the scope open within the web app."
  • Out-of-scope navigations open in an in-app browser. The same session: "In Home Screen web apps on iOS, links outside the scope will open in Safari View Controller." The user sees the target page in a sheet with a Done button and returns to your app when they close it.
  • Links from other apps never open the web app. iOS has no link capturing for web apps. A link to your site in a message or email opens the user's default browser, where the user may be signed out and the web app's data isn't available. Universal Links only apply to native apps.
  • Custom schemes such as tel:, mailto: and sms: hand off to the corresponding app as they do in Safari.

Choose scope so that all your own pages are in it. A scope that's too narrow makes your own pages open in the in-app browser, which feels broken. If your app spans subdomains, only one of them can be in scope on iOS (scope_extensions isn't supported), so keep app pages on one origin.

Authentication and OAuth in Home Screen web apps

Sign-in is where storage isolation and scope handling combine into real problems:

  1. Separate cookie jars. The web app starts with a copy of Safari's cookies (iOS 17.2 and later) and then diverges. A user who signs in inside the web app isn't signed in in Safari, and the reverse.
  2. Identity providers are out of scope. A redirect-based OAuth or OIDC flow navigates to the provider's origin, which is outside your scope. On macOS, Apple says "authentication through OAuth on a third-party domain will still open in your web app", based on "heuristics", and recommends window.open() for flows that must stay in the app because "links loaded through window.open will always open in the web app regardless of scope". Apple hasn't published equivalent guidance for iOS. Safari 17.2's release notes list fixes for "sign in pages sometimes unexpectedly open in Safari instead of the web app" and "some login pages unexpectedly open in Safari", without saying whether they applied to the Mac, iOS or both. Test every provider on a device.
  3. Magic links break. An email sign-in link opens in the default browser, not the web app, and signs the user in there. WWDC23 names this directly: "Since links from email will open in the default browser, this will not automatically sign users in to the web app that they already have."

Patterns that work reliably:

  • Passkeys and first-party sign-in. WebAuthn works in Home Screen web apps (iOS 13 and later for PublicKeyCredential), keeps the whole flow on your origin, and never leaves the app. Password forms on your own origin also stay in scope. See Authentication & Passkeys.
  • One-time codes instead of magic links. Email a short code the user types into the app, or send a link and a code, so users of the web app can complete sign-in without leaving it.
  • Server-side session establishment on your origin. Keep the OAuth callback URL on your origin and inside your scope, have the server exchange the code, and set an HTTP-only session cookie. Whatever context the flow finishes in, the result is a cookie in that context's jar, so make the final page tell the user where to continue if it detects it isn't running standalone.
  • Session hand-off for magic links. If a link opens in Safari, show "Continue in the app" with a short code the user can enter in the web app, rather than leaving them signed in only in the browser.

The callback page can detect which context it landed in and act accordingly:

auth-callback.js
// Runs on /auth/callback after the server has set the session cookie.
// Decides what to show depending on the context the OAuth flow finished in.

const standalone = navigator.standalone === true || matchMedia("(display-mode: standalone)").matches;
const status = document.querySelector("#auth-status");

if (window.opener && !window.opener.closed) {
  // Popup flow (window.open from the app): tell the opener and close.
  try {
    new BroadcastChannel("auth").postMessage({ type: "signed-in" });
  } catch {
    /* BroadcastChannel unavailable: the opener will re-check its session on focus */
  }
  window.close();
} else if (standalone) {
  // Redirect flow that stayed inside the web app: go to the app.
  location.replace("/");
} else if ("standalone" in navigator) {
  // Safari-family tab (iOS, iPadOS, or macOS Safari 17+): web apps there have their
  // own cookie jar, so the session cookie landed in the browser, not the web app.
  status.textContent =
    "You're signed in in this browser. If you use the installed app, open it and sign in there too.";
} else {
  location.replace("/");
}
app.js (excerpt)
// In the app: react to a popup sign-in, and re-check the session when the app regains focus.
const channel = "BroadcastChannel" in window ? new BroadcastChannel("auth") : null;
channel?.addEventListener("message", (event) => {
  if (event.data?.type === "signed-in") refreshSession();
});
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") refreshSession();
});

async function refreshSession() {
  try {
    const response = await fetch("/api/session", { credentials: "same-origin", cache: "no-store" });
    document.body.dataset.signedIn = String(response.ok);
  } catch {
    // Offline: keep the last known state rather than signing the user out in the UI.
  }
}

Push notifications and badging

Push is the only way an iOS web app can run code while it's closed, and it's available only to Home Screen web apps (iOS and iPadOS 16.4 and later). The requirements, in short:

  • The site runs as a Home Screen web app. On iOS 26 and later, any site the user added with Open as Web App on qualifies.
  • Permission is requested from a user gesture. Without one, WebKit rejects pushManager.subscribe() with NotAllowedError, and Notification.requestPermission() resolves "denied" without prompting.
  • userVisibleOnly: true and a VAPID applicationServerKey. Messages go through Apple's push service (endpoints under push.apple.com), with no Apple Developer account required.
  • Every push must show a notification. WebKit revokes push subscriptions for an origin after repeated pushes that don't.
  • Declarative Web Push (iOS 18.4 and later) lets the system show a notification from a JSON payload without running your service worker.

The Badging API works in Home Screen web apps from iOS 16.4. WebKit: permission "is granted in exactly the same way as other apps on iOS and iPadOS. Once a user gives permission to allow notifications, the icon on the Home Screen will immediately display the current badge count." Set the badge from the page while it's in the foreground, or from the service worker while handling a push.

These topics have their own pages: Web Push on iOS & Safari documents Apple's push service, the silent-push rule, error codes and declarative push; Badging API and Notifications API cover the rest.

Debugging Home Screen web apps with Web Inspector

Safari's Web Inspector on a Mac is the only full debugger for iOS web content. Apple's setup steps (Inspecting iOS and iPadOS):

  1. On the device: open Settings, go to Apps > Safari, scroll to Advanced, and turn on Web Inspector.
  2. On the Mac: in Safari's settings, enable the Develop menu (Settings > Advanced > Show features for web developers).
  3. Connect the device with a cable and trust the Mac when prompted. The device appears in Safari's Develop menu.
  4. Optionally enable Connect via Network from the device's submenu in the Develop menu, after the first cable connection, to inspect over Wi-Fi.

What you can inspect:

  • Home Screen web apps. "When a Home Screen web app is in the foreground, you can inspect it from the Develop menu. Find the menu item for the iOS or iPadOS device you wish to inspect, and then find the web app's URL in the Home Screen Web Apps section near the bottom of the menu."
  • Service workers. They appear in a Service Workers section of the device menu, but "you can only inspect service workers that are currently running". Safari 26 added automatic inspection and pausing of new service workers, which makes it possible to debug install, activate and push handlers from their first line.
  • Simulators. "Web Inspector is always enabled for simulators", and booted simulators appear in the Develop menu like devices. Home Screen web apps in the Simulator are listed under their own Home Screen Web Apps section (fixed in Safari 17.4). The Simulator is fine for layout, safe areas and storage, but test push, wake lock and performance on real hardware.
  • Web views in native apps. Only if the app marks its WKWebView as inspectable. In-app browsers usually don't.

A debugging workflow that avoids the most common confusion:

  1. Remove the web app from the Home Screen before a test session, then add it again. This gives you fresh storage, a fresh service worker and the current manifest and icons.
  2. Launch the app, then open Web Inspector from the Home Screen Web Apps section. Use the Storage tab to confirm which IndexedDB databases and caches exist in the web app's own store.
  3. To debug service worker startup, enable automatic inspection of new service workers before launching.
  4. Keep a production log channel for problems you can't reproduce while connected. A tiny error reporter covers most cases:
error-beacon.js
// Reports uncaught errors with the context that matters on iOS.
const context = {
  standalone: navigator.standalone === true,
  displayMode: matchMedia("(display-mode: standalone)").matches ? "standalone" : "browser",
  swControlled: Boolean(navigator.serviceWorker?.controller),
};

function report(kind, detail) {
  const body = JSON.stringify({ kind, detail, context, url: location.href, at: Date.now() });
  // sendBeacon survives the page being hidden; fall back to fetch keepalive.
  if (!navigator.sendBeacon?.("/api/client-errors", body)) {
    fetch("/api/client-errors", { method: "POST", body, keepalive: true }).catch(() => {});
  }
}

window.addEventListener("error", (event) => report("error", String(event.error?.stack ?? event.message)));
window.addEventListener("unhandledrejection", (event) => report("rejection", String(event.reason?.stack ?? event.reason)));

Browser DevTools compares Safari's tools with Chrome's and Firefox's.

The EU DMA episode and alternative browser engines

The European Union's Digital Markets Act required Apple to allow browser engines other than WebKit on iOS in the EU. How Apple implemented it briefly put Home Screen web apps at risk.

sequenceDiagram
    participant Apple
    participant EU as EU users on iOS 17.4 beta
    participant Devs as Web developers
    Apple->>EU: iOS 17.4 betas open Home Screen web apps as bookmarks
    Devs->>Apple: Reports, complaints, requests to keep web apps
    Apple->>Devs: Q&A explains removal ("security and privacy concerns")
    Apple->>Devs: Update, Home Screen web apps stay, built on WebKit
    Apple->>EU: iOS 17.4 ships (March 5, 2024) with web apps intact

What happened. In the EU betas of iOS 17.4, early in 2024, Home Screen web apps opened as ordinary bookmarks in the default browser, losing standalone windows, their own storage, push and badging. Apple's explanation, in its developer Q&A on the EU changes, was that keeping them "was informed by the complex security and privacy concerns associated with web apps to support alternative browser engines that would require building a new integration architecture that does not currently exist in iOS". Apple warned that without WebKit's isolation, "malicious web apps could read data from other web apps and recapture their permissions to gain access to a user's camera, microphone or location without a user's consent", and said EU users would still reach sites "through a bookmark with minimal impact to their functionality".

The reversal. Before iOS 17.4 shipped, Apple updated the page:

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.

Apple said developers and users affected by the beta "can expect the return of the existing functionality for Home Screen web apps with the availability of iOS 17.4 in early March". iOS 17.4 shipped on March 5, 2024, with Home Screen web apps working in the EU as they do elsewhere.

Alternative engines today. Apple's rules allow alternative engines in the EU on iOS 17.4 and later (iPadOS 18 and later), for dedicated browser apps and in-app browsing, under entitlements with strict requirements: 90% of the Web Platform Tests, 80% of Test262, memory-safe web content processing, third-party cookies blocked by default, storage partitioned per top-level site. Japan followed with iOS 26.2 under the Mobile Software Competition Act: "In iOS 26.2 and later, browser engines other than WebKit can be used in two types of apps for users in Japan." App Review Guideline 2.5.6 now points to both regions.

For PWAs, three facts matter as of September 2026:

  1. Home Screen web apps run on WebKit everywhere, including the EU and Japan, whichever browser created them. Apple hasn't documented a way for an alternative-engine browser to create Home Screen web apps on its own engine.
  2. No alternative engine has shipped to users. Open Web Advocacy wrote in July 2025 that "no browser vendor has ported their engine to iOS over the past 15 months", and its June 2026 post describes only a Blink prototype from Microsoft's Edge team running on iOS 26.5.1.
  3. Your iOS compatibility target is still WebKit's feature set. If a Blink or Gecko browser ships in the EU or Japan, its users will get that engine's features in the browser, but anything installed to the Home Screen will still be a WebKit web app.

Browser support

Support data as of September 2026, for iOS and iPadOS 27 (Safari 27). "Web app" means a Home Screen web app. For live data, see MDN's compatibility tables and caniuse.

Capability Safari tab (iOS) Home Screen web app (iOS) Safari on macOS (tab / Dock web app) Chrome on Android
Install ✅ Add to Home Screen (manual) – ✅ 17 Add to Dock ✅ prompt + beforeinstallprompt
Service workers ✅ 11.3 ✅ 11.3 ✅ 11.1 ✅
Web Push ❌ ✅ 16.4 ✅ 16.1 (macOS 13 Ventura; MDN lists 16) / ✅ 17 ✅
Declarative Web Push ❌ ✅ 18.4 ✅ 18.5 ❌
Badging API ❌ ✅ 16.4 ❌ / ✅ 17 ⚠️ no-op
Screen Wake Lock ✅ 16.4 ✅ 18.4 ✅ 16.4 ✅
Web Share ✅ 12.2 ✅ 12.2 ✅ 12.1 ✅
Background Sync / Periodic Sync / Background Fetch ❌ ❌ ❌ ✅
Share target, file handling, protocol handlers ❌ ❌ ❌ ⚠️ share target only
Manifest shortcuts ❌ ❌ ❌ / ✅ 17.4 ✅
theme-color meta tag applied ⚠️ 15 to 18 only1 ✅ 15 ⚠️ tabs until 18 / ✅ web apps ✅
Exempt from 7-day storage cap ❌ ✅ ❌ / not documented n/a
Persistent storage (persist()) ⚠️ heuristic ✅ favored by heuristic ⚠️ heuristic ✅ heuristic

The Platform Support matrix compares every browser.

Practical tips and workarounds

  • Detect the context, not the device. navigator.standalone distinguishes a tab (false) from a web app (true). It also exists in macOS Safari 17 and later, so add navigator.maxTouchPoints > 0 before showing iPhone-specific install steps. Use it for install hints and to explain why push isn't available in a tab.
  • Ask for installation where it unlocks something. On iOS, installation unlocks push, badging and the storage-deletion exemption. Tie the install hint to those moments.
  • Replace every background API with resume logic. Flush queues, refresh data and re-check push subscriptions on launch and visibilitychange.
  • Keep sessions in cookies. Cookies are copied into a new web app; localStorage and IndexedDB aren't.
  • Keep everything in one scope on one origin. Out-of-scope pages open in the in-app browser, and there's no scope_extensions.
  • Draw your own navigation. Back, refresh and share actions all need visible UI in standalone mode.
  • Design for the status bar. Choose a status bar style, add viewport-fit=cover if you go edge to edge, and pad with env(safe-area-inset-*).
  • Provide apple-touch-icon. It takes precedence over the manifest, and a missing icon produces a generated placeholder.
  • Get the manifest right before launch. Apple documents no update mechanism for existing installs, so a later name or icon change may never reach them.
  • Test on devices you support. Behavior changed substantially in 16.4, 17.2, 18.4 and 26. Keep at least one device on the oldest iOS version in your analytics.

Common pitfalls

  • Showing a "Turn on notifications" button in Safari tabs on iPhone. Notification is undefined there; show install instructions instead.
  • Assuming Chrome on iOS supports Chrome's PWA features. It's WebKit, with Safari's features, and its Add to Home Screen creates a WebKit web app.
  • Expecting beforeinstallprompt. It never fires on iOS or iPadOS, in any browser.
  • Relying on Background Sync to deliver offline writes. It doesn't exist on iOS. Writes queued while the app is closed wait until the next launch.
  • Storing auth tokens only in localStorage. A new web app gets Safari's cookies but not its localStorage, so users appear signed out.
  • Magic-link sign-in. Email links open in the browser, not the web app, and sign the user in there.
  • Testing on iOS 26 only. Users on iOS 18 and earlier get a bookmark unless your manifest declares standalone or fullscreen (or you use the legacy meta tag), and before 18.4 Screen Wake Lock doesn't work in web apps.
  • Forgetting the second copy. A user can install your site twice. Each copy has separate storage, permissions and push subscriptions, so key server-side push subscriptions by endpoint, not by user alone.
  • Disabling zoom to stop input zoom. Use 16 px form controls instead.

Further reading

On this site

External references


  1. MDN: "From Safari on iOS 26, the theme color is only used for installed web apps." The same note applies to Safari 26 on macOS. ↩