Skip to content

Web Development · Mobile Web

Progressive Web Apps in 2026: What Actually Works on iOS and Android

PWAs got a second wind when iOS 16.4 unlocked push notifications and iOS 17 improved install behavior. Three years later, the gap between PWA and native is narrow enough to matter.

Anurag Verma

Anurag Verma

8 min read

Progressive Web Apps in 2026: What Actually Works on iOS and Android

Sponsored

Share

For most of the last decade, “build a PWA” was advice that came with a long list of asterisks. Push notifications didn’t work on iOS. Install prompts were buried. Background sync was hit or miss. The pitch was compelling in principle and frustrating in practice, especially if your audience used iPhones.

That situation has changed. iOS 16.4 shipped push notification support for installed PWAs. iOS 17 improved the Add to Home Screen flow. By 2026, the major capability gaps between a well-built PWA and a native app have closed enough that “should we build a PWA or go native?” is a real question with a real answer — not just a theoretical exercise.

This post is about what actually works today, what still doesn’t, and how to build a PWA that feels like a first-class app.

What Changed on iOS

The historical iOS limitations came down to a few specific things: no push notifications for web apps, no background sync, and a crippled install experience. The modern state:

Push notifications are available on iOS 16.4+ via the Push API and Notification API, with one critical constraint: they only work when the PWA is installed to the home screen. A browser tab cannot receive push notifications on iOS, even if the user grants permission. The Safari team documented this as intentional, not a bug. Build your notification flows with this in mind — prompt for installation before prompting for notification permission.

Background sync remains incomplete on iOS. The Background Sync API is not supported. If you need to sync data when the app is in the background (uploading a form submission that was completed offline), you need a different pattern: handle it on next foreground open, or use a Service Worker fetch event to queue the request when connectivity is available.

Installation UX improved in iOS 17 with a small but useful change: the browser now surfaces the “Add to Home Screen” option more prominently when a site has a valid Web App Manifest with the correct fields. The app still doesn’t appear in the App Store, but for users who know what a home screen icon does, the friction is lower.

WebPush on Android via Chrome has worked for years. The gap was always iOS. With 16.4+, you now have a consistent story across the two major mobile platforms for installed PWAs.

The Manifest and Service Worker Foundation

A PWA that’s actually installable needs two things: a Web App Manifest and a Service Worker. Neither is optional.

The manifest:

{
  "name": "Your App Name",
  "short_name": "AppName",
  "description": "What it does",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1a1a2e",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/home.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ]
}

The screenshots field is new-ish and worth adding. Chrome on Android shows these in the install dialog, which improves conversion. The purpose: "maskable" on icons tells Android to crop the icon to fit the launcher’s shape — without this, your icon gets white-padded into a square on circular launcher slots.

The service worker — minimal viable version:

// sw.js
const CACHE_NAME = 'v1';
const STATIC_ASSETS = ['/', '/styles/main.css', '/scripts/app.js'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

self.addEventListener('fetch', (event) => {
  // Network-first for API calls, cache-first for static assets
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(event.request).catch(() => caches.match(event.request))
    );
    return;
  }

  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;
      return fetch(event.request).then((response) => {
        if (response.status === 200) {
          const copy = response.clone();
          caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
        }
        return response;
      });
    })
  );
});

The strategy here: network-first for dynamic API calls (so users get fresh data when online, cached data when not), cache-first for static assets (CSS, JS, images that don’t change often).

Register it from your main HTML:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').catch(console.error);
}

Push Notifications: The Full Flow

The iOS-compatible push notification flow has more steps than Android-only pushes. Here’s what works across both:

async function requestPushPermission() {
  // iOS requires a user gesture to call this
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // required
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
  });

  return subscription;
}

// Send the subscription to your server
async function saveSubscription(subscription) {
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
    headers: { 'Content-Type': 'application/json' },
  });
}

The service worker handles incoming pushes:

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? 'New notification', {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url ?? '/')
  );
});

On the server, send via the Web Push Protocol (not a vendor SDK). The web-push npm package handles VAPID key generation and the push request:

import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:your@email.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

await webpush.sendNotification(
  subscription, // the object your client saved
  JSON.stringify({ title: 'New message', body: 'Alice sent you a note', url: '/messages' })
);

Offline-First Data: IndexedDB

For apps that need to work offline with user data (forms, drafts, cached content), IndexedDB is the storage layer. The raw API is verbose, so use a wrapper. idb from Jake Archibald is 1.5KB and covers almost all use cases:

import { openDB } from 'idb';

const db = await openDB('myapp', 1, {
  upgrade(db) {
    db.createObjectStore('drafts', { keyPath: 'id', autoIncrement: true });
  },
});

// Save a draft
await db.put('drafts', { content: 'Work in progress...', updatedAt: Date.now() });

// Read all drafts
const drafts = await db.getAll('drafts');

Combine this with a sync strategy: when the user comes back online, check for unsynced records and POST them to the server. The online/offline events on window give you the hook:

window.addEventListener('online', syncPendingDrafts);

When to Choose a PWA Over Native

A PWA makes sense when:

  • Your primary users are on Android (Chrome on Android is the most capable PWA runtime)
  • The app is form-driven or content-heavy, not graphics-intensive
  • You want a single codebase for web and “app” without a React Native or Flutter abstraction
  • Discoverability via search or link-sharing matters more than App Store presence
  • Your team is stronger in web tech than native mobile

A native app still wins when:

  • You need deep hardware access (ARKit/ARCore, NFC, Bluetooth in complex ways)
  • You depend on App Store presence for revenue or discoverability
  • Your audience is predominantly iOS users who won’t install a home screen app
  • Background processing (location tracking, audio playback controls) is central to the experience

The PWA answer for a B2B SaaS tool used on desktop and Android tablets: yes, almost always. The PWA answer for a consumer social app where iOS is 60% of users: probably not yet.

Build Tooling

Frameworks that make PWA setup significantly easier:

Next.js with next-pwa — adds service worker generation and manifest support with minimal config. Pairs well if you’re already on Next.js.

Vite PWA Plugin (vite-plugin-pwa) — the most actively maintained PWA plugin for Vite-based projects (including Astro, SvelteKit, Nuxt). Generates the service worker using Workbox under the hood, with support for precaching, runtime caching strategies, and push registration.

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

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.yourapp\.com\/.*/i,
            handler: 'NetworkFirst',
            options: { cacheName: 'api-cache', networkTimeoutSeconds: 10 },
          },
        ],
      },
      manifest: {
        name: 'Your App',
        short_name: 'App',
        theme_color: '#1a1a2e',
        icons: [
          { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
    }),
  ],
};

Serwist is a TypeScript-first fork of Workbox that several teams have moved to after Google reduced Workbox investment. Worth evaluating if you need more fine-grained service worker control.

Lighthouse Scores Are Not the Goal

Lighthouse has a PWA audit that gives you a score. That score is a useful checklist, not a success metric. A PWA that passes every Lighthouse check but has no install prompt UX, no offline state, and a service worker that only caches fonts is not a useful PWA.

The metrics that matter are install rate (track with beforeinstallprompt), push opt-in rate, and sessions from home screen (trackable via display-mode: standalone in a CSS media query or via the navigator.standalone property on iOS).

Build for those, and Lighthouse follows naturally.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored