Skip to content
DERKONLINE

Build a PWA That Feels Native on iOS Despite Safari's Limits

Work around Safari's push, storage-eviction, and standalone-mode rules to ship an installable web app iPhone users actually keep.

Derrick S. K. Siawor8 min read

A progressive web app that feels native on an iPhone is entirely possible. It is just that Safari makes you earn it, and the places it pushes back are exactly the places a careless build falls apart: notifications, persistent data, and the moment the app launches in standalone mode and the browser chrome vanishes. Get those three right and an iPhone user installs your web app, keeps it, and never thinks about the fact that it is not on the App Store.

The trick is to design for Safari's rules from the start rather than fight them after launch. Most of the "iOS PWAs are broken" complaints come from teams that built against Chrome's permissive behaviour and then discovered, in production, that WebKit does things differently. Here is where the differences are and how to ship around them.

Push notifications work, but only on a strict path

Since iOS 16.4, a PWA can send web push notifications through the standard Web Push API. That is the headline. The fine print is what trips people up.

Web push on iOS only works when the site has been added to the home screen as a PWA. There is no push from a tab in Safari. The user must install the app first, and only then can your code call for notification permission. So your install prompt is now load-bearing. If users do not add the app to the home screen, push simply does not exist for them.

The sequence the platform demands:

iOS PWA web push prerequisite sequence: manifest, service worker, install, gesture permission

  • A valid web app manifest with the right display mode and icons.
  • An active service worker registered and controlling the page.
  • The app launched from the home screen icon, not from a Safari tab.
  • A permission request triggered by a real user gesture, after install, not on first paint.

Build your onboarding around that order. Treating the install prompt as a step in an onboarding flow that reaches first value, rather than a popup thrown at a cold visitor, is what gets users to actually add the app. Show the value, prompt the install, and only ask for notification permission once the user is in the installed app and has a reason to want notifications. Asking on first visit in a browser tab fails silently and wastes the one permission prompt you get.

One more honesty note: iOS web push has reliability rough edges. Service worker events do not always fire after a device restart, users can find themselves silently unsubscribed, and notification clicks have been reported opening the wrong URL. So treat push as a helpful re-engagement channel, not a delivery guarantee. The content of those messages matters as much as the delivery, which is why sending push people open instead of mute is its own discipline. If a message absolutely must reach the user, do not make push the only path it travels.

Storage will be evicted unless you understand the eviction rules

This is the limitation that silently corrupts the experience. Safari applies a seven-day cap on script-writable storage: localStorage, IndexedDB, cache storage, all of it. If a site is not visited for seven days, that data can be cleared. For a banking app or a notes app that a user opens occasionally, that means coming back to find their data gone.

The critical nuance, and the one that changes your whole design: the seven-day cap applies to sites accessed through Safari, not to apps launched from the home screen. Once your app is installed to the home screen, it runs in its own process with its own counter that resets every time the user opens the app. So an installed PWA does not run up the seven-day tally the way a tab does. The data survives until the user deletes the app.

That single fact reframes the storage problem. The defence is not a clever workaround, it is getting the user to install. Everything downstream depends on it. For users who do install, you still play it safe:

  • Request persistent storage. Call navigator.storage.persist() to ask WebKit to protect your origin from automatic eviction under storage pressure. Safari uses a least-recently-used eviction policy when the device is low on space, and persistent storage is your request to be spared.
  • Keep the cache lean. iOS holds caches to roughly 50MB and evicts the app shell of less-used origins first. Cache the shell (HTML, CSS, JS) aggressively and treat content as replaceable.
  • Re-cache the shell on every launch. A cheap service worker step that re-fetches and re-caches the core shell on startup means even if eviction happened, the next open repairs it.
  • Never treat IndexedDB as the source of truth on iOS. It has a long history of transaction failures and data loss on WebKit. Use it as a fast local mirror, with the server holding the canonical copy.
async function ensurePersistence() {
  if (navigator.storage && navigator.storage.persist) {
    const persisted = await navigator.storage.persisted();
    if (!persisted) await navigator.storage.persist();
  }
}

Standalone mode: make the chrome-less launch look intentional

When the app launches from the home screen, the browser UI disappears. That is what makes it feel native, and it is also where the small ugly details live. A few things to set deliberately:

  • display: "standalone" in the manifest gives you the chrome-less window. Provide the full icon set so iOS does not render a blurry screenshot.
  • Account for the safe areas. With no browser chrome, your header now sits under the notch or the Dynamic Island unless you respect env(safe-area-inset-top) and friends. A header that collides with the status bar is the fastest tell that a web app is masquerading as native.
  • Handle the lack of a back button. There is no browser back affordance in standalone mode. If your navigation assumes one, the user gets stuck. Build in-app navigation that does not depend on browser chrome.
  • Theme the status bar. Set apple-mobile-web-app-status-bar-style so the status bar text colour matches your header instead of clashing with it.

These are small touches, but they are the difference between "this feels like an app" and "this feels like a website pretending." The same instinct, never leaving the user staring at a blank window while the shell boots, is why an app shell that never shows a spinner matters as much on a PWA as it does on a native build.

Offline behaviour that degrades gracefully

iOS does not support Background Sync, so you cannot rely on the platform to retry failed requests for you when the network returns. The pattern that works is manual: when a mutation fails offline, write it to a local queue in IndexedDB, and replay the queue when the app comes back online and the user opens it. It is a few dozen lines, and it turns a flaky network from a wall of errors into a quiet catch-up that the user never notices. Once you have two writers reconciling against a server, you are squarely in the territory of designing offline-first sync that survives bad networks and resolving the conflicts that follow.

The mindset for the whole offline story on iOS is graceful degradation. Read paths should serve from cache when the network is down. Write paths should queue and reconcile. And nothing should present the user with a dead screen just because a request timed out in an elevator.

The build order that actually ships

The reason iOS PWAs feel broken so often is that teams test in Chrome, see everything work, and ship. Then a real iPhone reveals that push needs install, storage gets evicted in a tab, the header sits under the notch, and offline writes vanish. Every one of those is predictable if you test on the actual device, in standalone mode, on a real iPhone, the way the user will run it. The same Safari strictness shows up elsewhere too, from dev sites that break in Safari but not Chrome on localhost to Safari-only 520 errors when large cookies overflow nginx.

Whether a PWA is even the right call against a native build is its own decision, and when a PWA beats a native app for your budget is worth settling before you start.

That is also why we treat WebKit as a first-class target rather than an afterthought when we build mobile apps and installable web apps. Safari's strictness is annoying once and then it is a feature, because the constraints it enforces (install before push, persistence requested explicitly, safe areas respected) are exactly the things that separate an app a user keeps from one they delete after a day.

A PWA that respects Safari's rules can sit on a home screen indefinitely, send notifications, hold its data through the seven-day cap, and launch chrome-less with the status bar themed and the safe areas handled. At that point the only people who know it is a web app are the ones who built it. If you want a web app that earns a spot on the home screen and stays there, we are happy to look at what you have and tell you exactly where Safari is going to push back.