An installed PWA on iPhone: safe updates, deploys and iOS details

Updates that wait for the user, deploys that broke open tabs, a launch logo measured on the device, zooming fields, and Web Push for a local-first app.

· Published · Updated · 8 min read · Versions: vite-plugin-pwa 2, Workbox 7 · About the project →

A PWA added to an iPhone's home screen looks like a native app, and people treat it like one: they open it from the icon, leave it in the background for days, and expect it to keep working when a new version ships. Financial Plan, my budgeting app, is used mostly this way. This post collects what it took to make updates safe, survive deploys that replace files under an open tab, and get the iOS details right: a logo that jumped on launch, fields that zoomed the page, and push notifications.

How the app syncs its data offline is a separate topic, covered in local-first PWA sync.

Ship updates when the user is ready, not mid-sentence

The service worker precaches the whole app, so it opens offline and while the servers are down. The other side of that is that a new version doesn't appear by itself: the browser downloads it in the background, and it waits until the open pages are done with the old one.

The app decides when to switch:

  • A visible tab gets a toast, "a new version is ready", with a Reload button that stays until used. Reloading on its own could throw away a half-typed form.
  • A hidden tab switches at once. Nobody is looking, so there is nothing to interrupt.
  • Open tabs check for updates every 30 minutes and whenever they become visible again, because an installed app may stay open for days without a navigation that would trigger the browser's own check.

There is also a manual path in Settings › Version & updates. Every build writes a /version.json that is never cached, so the page can compare its own build ID with the latest one. That page distinguishes three states that are easy to merge by mistake: up to date, ready (a newer build is downloaded and applying it is one reload), and failed (a newer build exists, but it couldn't be downloaded, for example offline). Showing "up to date" when the download failed would be a lie.

Finally, the app calls navigator.storage.persist() so the browser doesn't evict its IndexedDB data under storage pressure. For a local-first app, that data may be the only copy of changes not yet synced.

A deploy can break the tabs that are already open

The bug: a tab running yesterday's version navigates to a page whose code is loaded lazily. That chunk's file name contains a content hash from yesterday's build, and today's deploy no longer has it. The host's single-page-app rewrite answered every unknown path with index.html, so the browser received HTML where it expected JavaScript, failed to parse it, and the page broke.

Three changes fixed it:

  1. Missing assets are a real 404. The rewrite now skips /assets/, so a request for a chunk that no longer exists fails cleanly instead of returning HTML.
  2. Caching follows what can change. Hashed files under /assets/ are cached for a year as immutable; index.html, the service worker scripts and version.json are no-cache, so the next load always asks for the current ones.
  3. A failed chunk moves the tab to the latest version. Vite fires a vite:preloadError event when a dynamic import fails. The app handles it by activating the waiting service worker if there is one, or reloading otherwise.

The recovery has a guard, because a real outage also makes chunks fail to load, and reloading forever would make it worse:

const last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
if (Date.now() - last < 30_000) {
  toast.error(t("pwa.loadFailed"), { id: "stale-version" });
  return false;
}

At most one automatic reload every 30 seconds; after that, the user sees an error and decides.

Sequence diagram between an open tab running yesterday's build and the host serving today's deploy. Before: the tab requests a chunk with an old hash, the SPA rewrite answers 200 with index.html, and the page breaks because it got HTML instead of JavaScript. After: the same request gets a 404 because the rewrite skips /assets/, and the vite:preloadError handler activates the waiting service worker or reloads, at most once every 30 seconds.Open tabyesterday's buildHosttoday's deployBeforeGET /assets/page-<old hash>.js200 index.html (SPA rewrite)HTML where JS wasexpected: page breaksAfterGET /assets/page-<old hash>.js404: rewrite skips /assets/vite:preloadError: activate the waiting serviceworker or reload, at most once per 30 s
A clean 404 turns a broken page into an error the app can recover from.

The launch logo that moved, and measuring instead of guessing

The app shows its logo from the very first paint, in plain HTML outside React, and fades it out once the first page is ready. On an installed iPhone app, the logo visibly jumped upward a moment after launch.

The first fix assumed the cause: the status bar appears a moment after the first paint and changes the page's top edge, so the logo was positioned from the bottom edge instead. It still moved. So a temporary build logged the real sizes on the device, and the numbers said something else entirely: the page is 844 px tall at the first paint and 797 px tall 74 ms later. Its bottom edge moves up past the home indicator; its top edge and 100vh stay where they are.

With that known, the fix was simple. In standalone mode, the splash is a fixed box 100vh tall from the top, and the logo sits in the middle of that box, which doesn't change:

@media (display-mode: standalone) {
  .splash {
    bottom: auto;
    height: 100vh;
  }
}

The logo still shifted on the screen as the page resized, so two days later one more step went in: in the installed app, a small classic script (public/splash.js) keeps the logo hidden until the page has its final size, on that resize or after 300 ms without one, and then fades it in over 150 ms.

The measuring code came out again in the commit right after the one that added it. The lesson is the useful part: two reasonable explanations of a layout shift were both wrong, and logging innerHeight frame by frame for the first 4 seconds on the real device took minutes.

Fields that zoomed the page

iOS zooms the page into any text field whose font size is under 16 px when it gets focus, in Safari and in the installed app alike. The app's fields were 14 px, so every tap on an amount zoomed in and left the layout shifted.

Fields are now 16 px on phones and 14 px from the sm breakpoint up, and the viewport sets maximum-scale=1. On iOS that stops the automatic zoom while still allowing pinch zoom; Chrome on Android disables pinch zoom with that setting unless the user forces zoom in its accessibility options, which is worth knowing before copying the meta tag.

A tab bar that minimizes like iOS's

On phones the app has a bottom tab bar that shrinks while scrolling down, as Safari's own toolbar does. The first version also grew back at the end of short pages, and iOS's rubber-band bounce past the end counted as scrolling up. The rules that made it feel native:

  • Only scrolling up brings the full bar back, never reaching the end.
  • The bounce past the top or the bottom is ignored.
  • It takes 24 px of movement in one direction to change state.
  • Pages that scroll less than 240 px always keep the full bar.
  • Every page starts with the full bar; restoring its scroll position isn't scrolling down.

Push notifications from a local-first app

Web Push works on iPhone only for web apps added to the home screen, since iOS 16.4. The app's reminders are things like a credit card bill due in three days, last month not closed yet, or a term deposit maturing.

The data lives on the device, but a phone can't run code on a schedule from a web app. So the server works the reminders out from the synced records, with the same calculation engine the app uses (it lives in a package shared by both). That avoids a second, slightly different implementation of "when is this bill due". A few details:

  • One notification a day, around 8:00 Vietnam time, with everything due. Each reminder is claimed in a table before it is sent, so a retry or a second server instance can't send it twice.
  • Amounts are hidden by default, because notifications show on the lock screen.
  • Declarative Web Push, available since iOS 18.4, lets the system show a notification from the payload alone, without waking a service worker. Other browsers get the same payload through a small push service worker that shows it and opens the right page on tap.
  • Subscriptions are cleaned up. Endpoints are accepted only on the browsers' push services, dropped on HTTP 404 or 410, and dropped if the app hasn't renewed them in 30 days. Signing out removes the device's subscription.

Opening the app clears the icon's badge through the Badging API, so the number on the icon is only the reminders not yet seen.

Summary

Problem What the app does
Updates interrupting work Toast with Reload in visible tabs, silent switch in hidden ones, checks every 30 min and on focus
"Up to date" when it isn't /version.json never cached; up to date, ready and failed shown apart
Deploys breaking open tabs 404 for missing assets, immutable hashed files, vite:preloadError recovery with a 30 s guard
Logo jumping on launch Measured on the device: the bottom edge moves; logo centred in a 100vh box, shown once the page has resized
Page zooming into fields 16 px fields on phones, maximum-scale=1
Reminders for an offline app Computed on the server with the shared engine; claimed before sending; amounts hidden

Most of these were found by using the app every day on an iPhone, not by testing it in a desktop browser with a phone-sized window. An installed PWA is a different environment, and it has to be tested as one.

If you've shipped updates to a PWA that people keep installed on their iPhones, I'd love to compare notes. My email is on the home page.