+1 (415) 843-4662

Shipping your Ionic app as a PWA: install, service worker updates and Web Push on iOS

Every Ionic team we work with already has a web build — it is how they develop. The question that keeps coming up in 2026 is whether that web build should also be a product: an installable PWA that covers the browser users, the Chromebook fleet, the kiosk in the warehouse, and the people who will never install a 90 MB app to book one appointment a year.

It can be. Ionic components are web components, and the same codebase that Capacitor wraps for the stores runs in a browser. But "it loads in Chrome" is not a PWA. This tutorial covers the four things that actually decide whether a shipped Ionic PWA is good: a manifest that passes installability, a service worker update flow that does not strand users on a stale build, Web Push — including on iOS, where it works but only after install — and a clean way to branch behaviour between the native and web builds of one codebase.

1. Manifest and installability

Ionic's starters ship a web build; the PWA bits you add yourself. For an Angular app, ng add @angular/pwa gets you most of it. For React or Vue, vite-plugin-pwa is the least painful route.

// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'prompt',          // never 'autoUpdate' — see section 2
      injectRegister: null,            // we register it ourselves
      workbox: {
        globPatterns: ['**/*.{js,css,html,svg,woff2}'],
        navigateFallback: 'index.html',
        navigateFallbackDenylist: [/^\/api\//],
        maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
      },
      manifest: {
        name: 'Example Field Ops',
        short_name: 'Field Ops',
        start_url: '/?source=pwa',
        scope: '/',
        display: 'standalone',
        background_color: '#ffffff',
        theme_color: '#3880ff',
        icons: [
          { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
          { src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
        ],
      },
    }),
  ],
});

The parts people get wrong:

  • A maskable icon. Without purpose: 'maskable' Android draws your square icon inside a white circle and it looks broken on the home screen.
  • navigateFallbackDenylist. Without it the service worker answers your API routes with index.html and you get mystifying JSON parse errors.
  • start_url with a marker. ?source=pwa lets analytics separate installed sessions from browser sessions; you will want that number.
  • Safe areas. display: 'standalone' removes the browser chrome, so your header sits under the status bar unless the viewport meta has viewport-fit=cover and your layout uses Ionic's safe-area variables. The same CSS you use for native edge-to-edge applies here.

2. The update flow (the part that bites)

registerType: 'autoUpdate' sounds right and is wrong for an app-like PWA. It swaps the service worker whenever it feels like it, which means a user can be mid-form when chunks for the old build disappear from the server and lazy routes start 404-ing. Prompt, and let the user choose:

// pwa-update.ts
import { registerSW } from 'virtual:pwa-register';
import { toastController } from '@ionic/core';

export function initPwaUpdates() {
  const updateSW = registerSW({
    onNeedRefresh: async () => {
      const toast = await toastController.create({
        message: 'A new version is available.',
        position: 'bottom',
        buttons: [{ text: 'Reload', role: 'reload' }, { text: 'Later', role: 'cancel' }],
        duration: 0,
      });
      await toast.present();
      const { role } = await toast.onDidDismiss();
      if (role === 'reload') await updateSW(true);   // skipWaiting + reload
    },
    onOfflineReady: () => console.info('[pwa] cached and ready offline'),
    onRegisteredSW(_url, reg) {
      // Check hourly; browsers only check on navigation otherwise,
      // and a standalone PWA can stay open for days.
      if (reg) setInterval(() => reg.update(), 60 * 60 * 1000);
    },
  });
}

Two more rules that save you support tickets:

  1. Keep old chunks on the CDN for at least one release cycle. Immutable, hashed filenames plus a 30-day retention policy means a client on the previous build can still lazy-load a route.
  2. Version your caches and your API contract together. Send the build hash as a request header; if the server sees a build it no longer supports, it returns 409 and the client forces an update instead of failing weirdly.

If you are already running Live Updates on the native side after moving off Appflow, treat the PWA update prompt as the same product decision — same cadence, same copy, same "critical update" escape hatch.

3. Web Push, including on iOS

Web Push works in Safari on iOS — but only for a site the user has added to the Home Screen, and the permission request must come from a user gesture. Plan the UX around that: no permission prompt on first paint, and on iOS a pre-prompt that explains the Add-to-Home-Screen step first.

// web-push.ts
const VAPID_PUBLIC = '<your base64url VAPID public key>';

export function pushSupported() {
  return 'serviceWorker' in navigator && 'PushManager' in window;
}

export function isStandalone() {
  return window.matchMedia('(display-mode: standalone)').matches
    || (window.navigator as any).standalone === true;   // iOS
}

export async function subscribeToPush(): Promise<boolean> {
  if (!pushSupported()) return false;
  // iOS: push is only available once installed to the Home Screen.
  if (isIos() && !isStandalone()) { showAddToHomeScreenSheet(); return false; }

  const permission = await Notification.requestPermission();   // must be in a click handler
  if (permission !== 'granted') return false;

  const reg = await navigator.serviceWorker.ready;
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC),
  });

  await fetch('/api/push/subscriptions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getToken()}` },
    body: JSON.stringify({ subscription: sub, platform: 'web' }),
  });
  return true;
}

Handle the event in your service worker — with vite-plugin-pwa use injectManifest so you own the worker file:

// sw.js (injectManifest strategy)
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(self.registration.showNotification(data.title ?? 'Update', {
    body: data.body,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge.png',
    data: { url: data.url ?? '/' },
    tag: data.tag,            // collapse duplicates
  }));
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data.url;
  event.waitUntil((async () => {
    const clientList = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
    const existing = clientList.find((c) => c.url.includes(self.registration.scope));
    if (existing) { await existing.focus(); return existing.navigate(url); }
    return self.clients.openWindow(url);
  })());
});

Server side, Web Push is VAPID-signed HTTP to the endpoint in the subscription — a different transport from FCM HTTP v1 and APNs, which your native build uses. Keep one devices table with a platform column and one send service that fans out to three transports; do not let the notification logic fork per platform. Prune subscriptions aggressively: a 404 or 410 from the push service means the subscription is dead, delete it on the spot.

4. One codebase, two shells

Capacitor gives you a clean runtime check, so feature-gate rather than fork:

import { Capacitor } from '@capacitor/core';

export const isNative = Capacitor.isNativePlatform();

export async function registerForPush() {
  if (isNative) {
    const { PushNotifications } = await import('@capacitor/push-notifications');
    const perm = await PushNotifications.requestPermissions();
    if (perm.receive !== 'granted') return false;
    await PushNotifications.register();
    return true;
  }
  const { subscribeToPush } = await import('./web-push');
  return subscribeToPush();
}

Apply the same shape to the rest of the native surface:

CapabilityNative buildPWA build
StorageCapacitor Preferences / SQLiteIndexedDB (same repository interface)
Camera@capacitor/camera<input type="file" capture> / getUserMedia
Biometricsnative pluginWebAuthn / passkeys (works in both)
Share@capacitor/sharenavigator.share with a copy-link fallback
In-app purchaseStoreKit 2 / Play Billingyour own web checkout
BLE / NFCnative pluginshide the feature; say why

Dynamic-import the native plugins (as above) so the web bundle never ships them. And be honest in the UI: a disabled control with "available in the app" beats a button that silently does nothing.

A billing note that matters for strategy: if your product sells subscriptions, the web checkout in the PWA is yours and the store build is subject to store commission rules — which have moved more than once in the last two years. Decide deliberately which surface sells, and keep entitlements on your server so a purchase on either one unlocks both.

5. Ship checklist

  • Lighthouse PWA audit passes on the production URL (HTTPS, manifest, offline response for start_url)
  • Installed and tested on Android Chrome and iOS Safari Home Screen — these behave differently enough that one is not a proxy for the other
  • Update prompt verified: deploy a new build, confirm the toast appears within the hour and reload lands on the new version
  • Web Push verified end to end on iOS after Add to Home Screen, including a notification tap that deep-links into the right route
  • Offline: airplane mode, cold start, app shell renders and queued writes flush on reconnect
  • Analytics splits installed PWA sessions from browser sessions so you can actually see whether this was worth it

When a PWA is the wrong answer

Skip it if your app's value depends on background location, BLE hardware, widgets or Live Activities, aggressive background sync, or a native-only SDK your partner requires. Those are native-shell features and always will be. The PWA is leverage for the top of the funnel, internal tools, and low-frequency consumer use — not a replacement for the store build.

We build and audit this exact setup on Ionic codebases every month. If you want a second pair of eyes on your update flow, your push fan-out or the native/web feature split, get in touch — or read more about how we work with Capacitor consultants and plugin development.