Web Development · Mobile Web
Progressive Web Apps in 2026: What Works on iOS, Android
PWAs got a second wind once iOS 16.4 enabled push notifications and iOS 17 improved install flow. The gap between PWA and native is now narrow enough to matter.
Anurag Verma
8 min read
Sponsored
For most of the last decade, building progressive web apps 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 made sense on paper and fell apart 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. See MDN’s Web App Manifest reference for the full field list.
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. We cover the full server-side setup separately in our web push notifications implementation guide. 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. For a fuller side-by-side, see our PWA vs. React Native vs. Flutter comparison.
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, alongside the newer Core Web Vitals metrics we cover in our INP and Core Web Vitals guide. 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.
Frequently asked questions
- Do push notifications work in a PWA on iOS?
- Yes, since iOS 16.4, but only when the PWA has been added to the home screen. A PWA still running in a Safari browser tab cannot receive push notifications, even with permission granted.
- Does background sync work on iOS PWAs?
- No. The Background Sync API is not supported on iOS. Sync offline data by checking for unsynced records on the next foreground open, or by queuing requests in a service worker fetch handler.
- What are the minimum requirements for a PWA to be installable?
- A valid Web App Manifest with the required fields (name, icons, start_url, display) and a registered Service Worker. Both are required; a manifest alone will not trigger an install prompt.
- Should I build a PWA or a native app in 2026?
- A PWA makes sense for form-driven or content-heavy apps, especially with an Android-heavy audience. Native still wins when you need deep hardware access, App Store discoverability, or background processing like location tracking.
- What tooling makes PWA setup easier?
- Vite PWA Plugin for Vite-based projects (including Astro and SvelteKit), next-pwa for Next.js, and Serwist as a TypeScript-first Workbox alternative for teams needing finer service worker control.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored