Blog
·14 min

Applications Web Progressives : Une Plongée Profonde dans les Capacités Web Modernes

Service workers, offline-first architecture, push notifications, and everything you need to build PWAs that compete with native apps.

Progressive Web Apps represent a fundamental shift in how we think about the web. They take the reach and accessibility of a website and layer on capabilities that were once exclusive to native applications. Offline support, push notifications, background synchronization, and home-screen installation are no longer platform-specific features — they are web standards that any modern browser supports.

PWA is not a single technology. It is an umbrella term for a collection of APIs and design patterns that, when combined, produce an experience indistinguishable from a native app in many scenarios. The core requirements — HTTPS, a service worker, and a Web App Manifest — are straightforward to implement, but the design decisions around caching, offline behavior, and storage architecture require careful consideration.

This guide covers every layer of a production-grade PWA: the service worker lifecycle, caching strategies, the manifest and installability, push notifications, background sync, client-side storage with IndexedDB, using Workbox to simplify service worker management, the PWA versus native tradeoffs, the well-known iOS limitations, and how to audit your PWA with Lighthouse. Each section includes practical examples and concrete recommendations.

What Makes a PWA and Why It Matters

A Progressive Web App must meet three baseline criteria defined by the Web App Manifest specification and the service worker standard. First, it must be served over HTTPS so that intermediaries cannot tamper with the application code. Second, it must register a service worker that enables offline functionality and background processing. Third, it must include a Web App Manifest JSON file that tells the browser how the application should behave when installed on the user's device.

Beyond these technical requirements, PWAs embody a design philosophy: progressive enhancement. A user on a modern browser with a fast connection gets the full experience — offline support, push notifications, smooth animations. A user on an older browser or a slow network still gets a functional, accessible website. The application degrades gracefully because the core content and routing work without JavaScript, and the enhanced features layer on top only when the browser supports them.

The business case for PWAs is well-documented. Companies that have invested in PWA technology report significant improvements in user engagement, conversion rates, and performance. Pinterest rebuilt their mobile web experience as a PWA and saw a 60 % increase in core engagements. Twitter Lite reduced data consumption by 70 % and increased pages per session. Forbes saw a 43 % increase in sessions per user. These results come from the combination of instant loading (cached shells), re-engagement through push notifications, and the lower friction of a web install compared to an app store download.

Service Workers: Lifecycle and Capabilities

The service worker is the engine of any PWA. It is a JavaScript file that runs in the background, separate from the main browser thread, and acts as a programmable network proxy. It can intercept network requests, serve cached responses, sync data in the background, and receive push events — all while the web page is not open.

The service worker lifecycle has three distinct phases: registration, installation, and activation. Registration happens when your web page calls navigator.serviceWorker.register. The browser downloads the service worker file, parses it, and moves it through the lifecycle. Understanding this lifecycle is critical because errors in any phase can silently break your offline experience.

// Service Worker Registration with Lifecycle Logging
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(reg => {
      console.log('Service worker registered scope:', reg.scope);

      reg.addEventListener('updatefound', () => {
        const newWorker = reg.installing;
        newWorker.addEventListener('statechange', () => {
          console.log('Worker state:', newWorker.state);
          // States: 'installing' | 'installed' | 'activating' | 'activated' | 'redundant'
        });
      });
    })
    .catch(err => console.error('Registration failed:', err));
}

// Service Worker Install Event
self.addEventListener('install', (event) => {
  console.log('Service worker installing');
  // Force the waiting service worker to become active immediately
  self.skipWaiting();

  // Pre-cache critical resources
  event.waitUntil(
    caches.open('static-v1').then(cache =>
      cache.addAll([
        '/',
        '/styles/main.css',
        '/scripts/app.js',
        '/offline.html',
      ])
    )
  );
});

// Service Worker Activate Event
self.addEventListener('activate', (event) => {
  console.log('Service worker activating');
  // Claim all clients so the service worker controls them immediately
  event.waitUntil(clients.claim());

  // Clean up old cache versions
  event.waitUntil(
    caches.keys().then(keys =>
      Promise.all(
        keys
          .filter(key => key !== 'static-v1')
          .map(key => caches.delete(key))
      )
    )
  );
});

The install event is where you populate the cache with static assets — your app shell, CSS, JavaScript, fonts, and images that never change between deployments. The activate event is where you clean up old caches and take control of open pages using clients.claim. A common mistake is skipping cache cleanup during activation, which causes the user's device to store multiple versions of every asset indefinitely.

Service Worker Scope and Versioning

A service worker controls pages within its scope. If your service worker is at /sw.js, it controls all pages under /. If you place it at /app/sw.js, it only controls pages under /app/. The scope cannot be widened beyond the service worker's directory — this is a security measure to prevent arbitrary interception of requests. Every deployment should change the service worker file, even if the logic is identical, because the browser checks for byte-level differences to trigger updates.

Versioning your caches is essential for clean upgrades. Use a naming convention like static-v1, static-v2, and dynamic-v1 so the activate handler can delete obsolete versions. Never overwrite a cache with new content — always create a new cache and delete the old one. This ensures that a page running an old version of your app never receives assets from a newer cache that it does not understand.

A service worker that fails to compile during registration will be skipped silently. Always test your service worker locally before deploying, and always include error handling in your install and activate handlers.

Caching Strategies Explained

The caching strategy you choose determines the behavior of your application when the network is unavailable, slow, or unreliable. Different types of content require different strategies. Your app shell should be cached differently from your API responses, and your images should be cached differently from your user-specific data. There are four primary strategies that serve as the foundation for most PWA architectures.

Cache First (Cache with Network Fallback)

Cache First checks the cache for a response and returns it if found. If the resource is not in the cache, the service worker fetches it from the network, caches the response for future requests, and returns it. This strategy produces the fastest possible load times because the response comes directly from the device's storage with no network latency. It is ideal for static assets, app shell resources, and any content that does not change frequently.

Network First (Network with Cache Fallback)

Network First tries to fetch the resource from the network first. If the network request succeeds, the response is cached and returned. If the network fails — the user is offline, the server is down, or the request times out — the service worker falls back to the cache. This strategy prioritizes freshness over speed. It is the right choice for API responses, user-specific data, and content that changes frequently.

Stale-While-Revalidate

Stale-While-Revalidate serves the cached version immediately while simultaneously fetching a fresh version from the network. The fresh response replaces the cache for the next request. This gives you the speed of cache-first behavior with the freshness of network-first behavior. It is the ideal strategy for resources that update occasionally but do not need to be perfectly current — news feeds, social media timelines, and product listings all benefit from this approach.

// Caching Strategy Implementations

const CACHE_NAMES = {
  static: 'static-v1',
  dynamic: 'dynamic-v1',
};

// Cache First
async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  const cache = await caches.open(CACHE_NAMES.dynamic);
  cache.put(request, response.clone());
  return response;
}

// Network First
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(CACHE_NAMES.dynamic);
    cache.put(request, response.clone());
    return response;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;
    return new Response('Offline', { status: 503 });
  }
}

// Stale-While-Revalidate
async function staleWhileRevalidate(request) {
  const cache = await caches.open(CACHE_NAMES.dynamic);

  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then(response => {
    cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

// Fetch event handler routing requests to strategies
self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (url.origin !== location.origin) {
    // Cross-origin requests (CDN, API)
    if (url.pathname.startsWith('/api/')) {
      event.respondWith(networkFirst(request));
    } else {
      event.respondWith(staleWhileRevalidate(request));
    }
  } else if (url.pathname.startsWith('/static/')) {
    event.respondWith(cacheFirst(request));
  } else {
    event.respondWith(networkFirst(request));
  }
});

Most production PWAs use a combination of these strategies. The fetch event handler inspects the incoming request and routes it to the appropriate strategy based on the URL pattern, request method, or content type. This routing is the most important architectural decision in your service worker — getting it right means your application loads instantly from cache for repeat visits while staying fresh for dynamic content.

Offline-First Architecture Patterns

Offline-first means designing your application so that offline is not a failure mode — it is the default mode. The network becomes an enhancement on top of a local-first experience. This is a fundamental mindset shift from traditional web development, where the network is assumed to be available and offline is an error state that needs special handling.

The most common offline-first pattern is the app shell architecture. The application shell — the minimal HTML, CSS, and JavaScript required to render the user interface — is cached during the service worker install event. Every subsequent navigation loads from the cache instantly, and the dynamic content fills in from the network or a local data store. This gives the user the same instant loading experience whether they are on a fast connection, a slow connection, or completely offline.

  • App shell pre-caching: Cache the HTML skeleton, CSS framework, and JavaScript bundle during the service worker install event. The shell loads instantly on every subsequent visit regardless of network state.
  • Content hydration: After the shell loads, fetch the dynamic content from the network if available. If the network is unavailable, fall back to IndexedDB or display a cached version with a banner indicating stale data.
  • Background sync queue: When the user performs a write operation offline — submitting a form, creating a post, updating a profile — serialize the request to IndexedDB and replay it when the network becomes available using the Sync API.
  • Optimistic UI updates: Update the user interface immediately when the user performs an action, even before the network confirms the change. If the network request fails, roll back the UI and show an error notification.

Offline-first is not the same as offline-only. A well-designed offline-first application still fetches fresh data whenever the network is available, and it gives the user visibility into the freshness of the data they are viewing. A simple indicator showing Last updated 3 minutes ago is enough to build trust. The goal is to eliminate the loading spinner as the default experience and replace it with instant local data that updates silently in the background.

Offline-first is not about supporting users who are never online. It is about eliminating the blank loading state that plagues mobile web experiences on unreliable networks. Every user benefits from instant loading, even when the network is working perfectly.

Web App Manifest and Installability

The Web App Manifest is a JSON file that tells the browser how your application should behave when installed on the user's device. It controls the application name, icons, start URL, display mode, orientation, theme color, and background color. Without a manifest, the browser does not treat your site as a candidate for installation, and many PWA features remain disabled.

The manifest links from every page of your application through a <link rel=manifest href=/manifest.json> tag in the HTML head. Browsers periodically check for manifest updates, but the user must re-install the application to pick up changes. Versioning the manifest file through a query parameter or a unique filename is good practice when you plan to update the manifest metadata.

  • display: standalone removes browser chrome and makes the app open in its own window, which is the most native-like experience.
  • display: minimal-ui keeps basic navigation controls like the address bar while still offering an installed experience.
  • display: fullscreen hides everything and is appropriate for games and media applications.
  • display: browser is the default and provides no installation benefits — avoid it for PWAs.

The installability criteria vary by browser, but they all require a valid manifest with a short name, icons of at least 192x192 pixels, a service worker registered and active, and HTTPS. Chrome also requires that the application has been visited at least twice with at least five minutes between visits. These criteria ensure that only applications the user engages with regularly trigger the install prompt, reducing the noise of premature installation requests.

Push Notifications

Push notifications are the primary re-engagement channel for PWAs. Unlike native push notifications that go through platform-specific services like APNs or FCM, web push notifications use the Push API and the Notification API together. The Push API delivers the message from your server to the service worker even when the browser is closed. The Notification API then displays the message to the user from the service worker context.

The push flow involves several steps. First, your application requests notification permission from the user. If granted, it subscribes to push messages using the PushManager API, which generates a subscription object containing an endpoint URL and encryption keys. Your application sends this subscription to your server, which stores it. When you want to send a notification, your server posts the payload to the subscription endpoint, and the browser wakes up your service worker to handle the push event.

// Client-side push subscription with VAPID
async function subscribeToPush(registration) {
  const publicKey = 'YOUR_VAPID_PUBLIC_KEY';

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(publicKey),
  });

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

  return subscription;
}

// Service Worker Push Event Handler
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {
    title: 'Default Title',
    body: 'Default body',
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-96.png',
    data: { url: '/' },
  };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: data.icon,
      badge: data.badge,
      vibrate: [200, 100, 200],
      actions: data.actions ?? [],
      data: data.data,
    })
  );
});

// Notification Click Handler
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const url = event.notification.data?.url ?? '/';

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then(clientList => {
      for (const client of clientList) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      return clients.openWindow(url);
    })
  );
});

VAPID (Voluntary Application Server Identification) is required for web push. It associates your server with the push messages it sends and prevents unauthorized entities from sending notifications to your users. The VAPID keys are generated once and stored on your server. The public key is included in the subscribe call, and the private key signs every push request your server sends. Without VAPID, major browsers reject push messages.

Push notification permission must be requested in response to a user gesture, such as a button click. Requesting permission on page load triggers browser blocking and reduces acceptance rates by over 60 %. Ask at the right moment, not the first moment.

Background Sync and Periodic Sync

The Background Sync API allows a service worker to defer actions until the user has stable network connectivity. This is essential for offline-first applications where the user may submit a form or create content while offline. Instead of losing that data or forcing the user to wait, the service worker serializes the request, registers a sync event, and replays it when the network becomes available.

Periodic Sync is a related but distinct API that allows the service worker to fetch fresh content at regular intervals even when the application is not open. This is the web equivalent of a native application's background refresh. News applications, social feeds, and email clients use periodic sync to keep cached content fresh so that when the user opens the application, they see the latest data without waiting for network requests.

// Registering a background sync after an offline action
async function submitOfflineForm(data) {
  // Save the form data to IndexedDB
  await saveToSyncQueue(data);

  // Register a sync event with a unique tag
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-pending-forms');
}

// Service Worker Sync Event Handler
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-pending-forms') {
    event.waitUntil(processSyncQueue());
  }
});

async function processSyncQueue() {
  const pendingItems = await getSyncQueue();

  for (const item of pendingItems) {
    try {
      const response = await fetch(item.url, {
        method: item.method,
        headers: item.headers,
        body: item.body,
      });

      if (response.ok) {
        await removeFromSyncQueue(item.id);
      }
    } catch (error) {
      console.error('Sync failed for item', item.id, error);
      // The browser will retry; exponential backoff is built-in
      break;
    }
  }
}

// Periodic Sync Registration
async function registerPeriodicSync() {
  const registration = await navigator.serviceWorker.ready;

  // Check for permission (required by most browsers)
  const status = await navigator.permissions.query({
    name: 'periodic-background-sync',
  });

  if (status.state === 'granted') {
    await registration.periodicSync.register('refresh-feed', {
      minInterval: 24 * 60 * 60 * 1000, // Once per day
    });
  }
}

Background sync is well-supported and reliable across browsers. Periodic sync has more limited support — Chrome supports it on Android, but iOS Safari does not implement it at all. When periodic sync is unavailable, fall back to fetching fresh content when the user opens the application. The sync event handler should always be idempotent: if a sync operation runs twice for the same data, the second run should be a no-op.

IndexedDB and Client-Side Storage

Service workers can cache network responses, but caching alone is not enough for many applications. You also need structured client-side storage — a database that lives in the browser and survives page reloads. IndexedDB is the standard solution for this. It is a transactional, object-oriented database built into every modern browser, and it provides the storage layer that offline-first applications depend on.

Unlike localStorage, which is synchronous, limited to string key-value pairs, and capped at around 5 MB, IndexedDB is asynchronous, supports structured data including binary blobs, and offers storage limits determined by the browser's available disk space. The asynchronous API is critical for service worker contexts because synchronous operations block the event loop and can cause the browser to terminate the service worker.

// IndexedDB Wrapper for Offline Data Store
const DB_NAME = 'PWAStore';
const DB_VERSION = 2;

function openDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;

      if (!db.objectStoreNames.contains('posts')) {
        db.createObjectStore('posts', { keyPath: 'id' });
      }

      if (!db.objectStoreNames.contains('sync_queue')) {
        const store = db.createObjectStore('sync_queue', {
          keyPath: 'id',
          autoIncrement: true,
        });
        store.createIndex('status', 'status', { unique: false });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function savePosts(posts) {
  const db = await openDB();
  const tx = db.transaction('posts', 'readwrite');
  const store = tx.objectStore('posts');

  for (const post of posts) {
    store.put(post); // Upsert
  }

  await new Promise((resolve, reject) => {
    tx.oncomplete = resolve;
    tx.onerror = reject;
  });
}

async function getPost(id) {
  const db = await openDB();
  const tx = db.transaction('posts', 'readonly');
  const store = tx.objectStore('posts');
  return store.get(id);
}

async function getAllPosts() {
  const db = await openDB();
  const tx = db.transaction('posts', 'readonly');
  const store = tx.objectStore('posts');
  return store.getAll();
}

IndexedDB queries are performed within transactions that automatically commit when all requests complete and the transaction goes out of scope. This means you cannot mix async operations across transactions without careful handling. A common mistake is opening a transaction, starting an async operation like `fetch`, and expecting the transaction to remain active when the async operation resolves. It will not — the transaction auto-commits when control returns to the event loop.

  • IndexedDB is the right choice for structured data, user-generated content, sync queues, and any data that needs querying by keys or indexes.
  • CacheStorage (the caches API used by service workers) is the right choice for network responses, static assets, and opaque responses from CDNs.
  • localStorage is appropriate for simple preferences, flags, and small non-critical state that does not need transactional integrity.
  • SessionStorage should only be used for ephemeral state that should not survive tab closure, such as wizard form progress.
IndexedDB is available inside the service worker scope and the main thread scope, but the database instance is shared. Always version your database schema and handle the onupgradeneeded event gracefully, because two tabs running different versions of your application can access the same database simultaneously.

Workbox for Production Service Workers

Writing service worker code by hand is educational but rarely practical for production applications. The service worker must handle cache versioning, request routing, strategy selection, background sync, and push events. Hand-rolled implementations inevitably miss edge cases — a cross-origin request that fails, a cache that fills the user's disk, or a race condition between the install and activate events. Workbox, developed by the Google Chrome team, solves these problems with a library of well-tested modules.

Workbox is best integrated as a build-time tool. The workbox-webpack-plugin, workbox-vite-plugin, or workbox-build CLI generates the service worker file during your build process, injecting the list of precached assets and their revision hashes. This eliminates the most error-prone part of service worker development: keeping the precache manifest in sync with the actual build output. When you add or change a file, the build plugin automatically updates the service worker manifest.

// Workbox Webpack Plugin Configuration
const { InjectManifest } = require('workbox-webpack-plugin');

module.exports = {
  // ... other webpack config
  plugins: [
    new InjectManifest({
      swSrc: './src/sw.js',        // Your custom service worker template
      swDest: 'sw.js',              // Output path in the build directory
      maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,  // 5 MB
    }),
  ],
};

// src/sw.js — Workbox service worker template
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  NetworkFirst,
  CacheFirst,
  StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// This import is replaced at build time with the actual precache manifest
precacheAndRoute(self.__WB_MANIFEST);

// Cache Google Fonts with CacheFirst and expiration
registerRoute(
  /^https:\/\/fonts\.googleapis\.com/,
  new CacheFirst({
    cacheName: 'google-fonts',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 30,
        maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
      }),
    ],
  })
);

// Cache API responses with NetworkFirst
registerRoute(
  /\/api\//,
  new NetworkFirst({
    cacheName: 'api-responses',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 60 * 60, // 1 hour
      }),
    ],
  })
);

// Cache images with StaleWhileRevalidate
registerRoute(
  /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
      }),
    ],
  })
);

Workbox modules are tree-shakeable, so your production service worker only includes the strategies and plugins you actually use. The ExpirationPlugin prevents cache bloat by limiting the number of entries and the maximum age of cached responses. The CacheableResponsePlugin ensures you only cache successful responses — a 404 or 500 response should never replace a valid cached version. The BroadcastUpdatePlugin notifies your web application when cached content has been updated in the background, allowing you to show a notification to the user.

  • workbox-precaching handles the precache manifest, revision tracking, and cache-first serving of build assets.
  • workbox-routing provides the URL matching and strategy dispatch that would otherwise require manual implementation.
  • workbox-strategies includes CacheFirst, NetworkFirst, StaleWhileRevalidate, NetworkOnly, and CacheOnly.
  • workbox-expiration adds automatic cleanup of old cached entries based on count, age, or both.
  • workbox-background-sync provides a drop-in solution for queueing failed network requests and replaying them later.
  • workbox-window handles common PWA interactions from the main thread, including service worker updates and install prompts.

PWA vs Native: When Each Makes Sense

The decision between building a PWA and building a native application depends on your specific requirements, audience, and resources. Neither approach is universally superior. The right choice is the one that best serves your users and your business goals. Understanding the tradeoffs is essential for making an informed decision rather than following hype or tradition.

PWAs excel in scenarios where reach, discoverability, and low friction matter most. A user can start using a PWA by visiting a URL — no app store, no download wait, no installation dialog that requires confirmation. The update mechanism is seamless because the latest version is served on every navigation. PWAs are inherently cross-platform because they run in any modern browser on any operating system. For content-driven applications, news sites, e-commerce storefronts, and utility tools, PWAs often provide a superior user experience to native apps.

  • Choose PWA when reach and instant access matter more than platform-specific features. Content sites, e-commerce, travel booking, and productivity tools are excellent PWA candidates.
  • Choose native when you need deep hardware access, background audio, Bluetooth or NFC connectivity, or advanced camera controls that the Web API surface does not cover.
  • Choose hybrid (PWA plus native wrapper) when you want the broad reach of the web with the distribution and discovery benefits of the app stores.

Native applications retain advantages in several areas. They have access to the full device API surface — Bluetooth, NFC, USB, advanced camera controls, background audio playback, and file system access beyond the Origin Private File System. They can run true background services that are not subject to the browser's lifetime limits. They integrate with platform-specific features like App Clips, Widgets, and Siri Shortcuts on iOS or Android Widgets, and they can participate in system-level sharing, authentication, and payment flows.

The gap between PWA and native capabilities is narrowing every year. File System Access, Web Bluetooth, Web NFC, WebUSB, Async Clipboard, and the Screen Wake Lock API have brought most essential hardware interactions to the web. The remaining gaps — advanced background execution and full offline media playback — are actively being standardized.

iOS Safari Limitations and Workarounds

iOS Safari is the most restricted browser environment for PWAs. Apple has been slow to adopt several PWA-enabling technologies, and some limitations are deliberate design decisions rather than technical constraints. Understanding these limitations is essential because iOS represents a significant share of mobile web traffic, and a PWA that does not work properly on iPhones is not a complete solution.

The most impactful limitation is that iOS Safari does not support the Push API and the Notification API. Web push notifications are simply unavailable on iOS regardless of the browser you use, because Apple requires all third-party browsers to use WebKit, and WebKit does not implement the Push API. This eliminates the primary re-engagement channel for PWAs on iOS. The only workaround is to implement email or SMS notifications as a fallback, but these lack the immediacy and low friction of push notifications.

  • Push notifications: Unavailable on iOS. Fall back to email or in-app notification badges that update when the user opens the app.
  • Periodic background sync: Unavailable. Fetch fresh content on app open instead of relying on background refresh.
  • Cache persistence: Safari may evict service worker caches after 7-14 days of non-use. Implement a cache-warming strategy that re-caches critical assets periodically.
  • Install prompt: Safari does not show the native install banner. Users must manually tap Share and then Add to Home Screen.
  • Blob storage: Safari limits blob URL lifetimes, which can break video and image playback from cached responses. Consider using responses directly instead of blob URLs.

Despite these limitations, PWAs on iOS still provide meaningful benefits. The app shell loads instantly from cache, reducing the perceived load time significantly. The standalone display mode removes browser chrome and provides a native-like navigation experience. Icon badges, though not push-driven, can be updated when the user opens the application. The performance improvements from caching and the offline experience for previously visited pages are enough to justify building a PWA even without full iOS feature parity.

Auditing PWAs with Lighthouse

Lighthouse is Google's automated auditing tool for web applications. It generates a detailed report covering performance, accessibility, best practices, SEO, and PWA compliance. The PWA audit checks approximately 14 criteria organized into categories: installability, service worker, offline behavior, and manifest configuration. Failing any of these audits means your application does not meet the baseline definition of a PWA.

Running a Lighthouse audit is straightforward. Open Chrome DevTools, navigate to the Lighthouse tab, select the categories you want to audit, and click Generate Report. For PWA audits specifically, you should audit on a mobile device or with mobile device emulation enabled because some criteria, like viewport configuration and tap target sizing, are mobile-specific. Lighthouse also supports CI integration through Lighthouse CI, which allows you to enforce PWA standards in your deployment pipeline.

// Lighthouse CI Configuration for PWA Auditing
// .lighthouseci.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: ['http://localhost:3000'],
      numberOfRuns: 3,
      settings: {
        preset: 'desktop',
        // Or use 'mobile' for mobile-specific audits
      },
    },
    assert: {
      assertions: {
        // PWA category must score 100
        'categories:pwa': ['error', { minScore: 1 }],

        // Individual PWA audits
        'service-worker': 'error',
        'installable-manifest': 'error',
        'splash-screen': 'error',
        'themed-omnibox': 'error',
        'content-width': 'error',
        'viewport': 'error',
        'apple-touch-icon': 'error',

        // Performance budgets
        'categories:performance': ['warn', { minScore: 0.9 }],
        'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

The most commonly failed PWA audits are missing apple-touch-icon link, which is required for iOS Safari to display a splash screen, and incorrect theme-color configuration. The apple-touch-icon should be at least 180x180 pixels for modern iOS devices, and the theme-color meta tag should match the theme_color property in your manifest to prevent a flash of incorrect color during the splash screen transition.

  • Register a service worker and verify it responds in the Application > Service Workers panel of DevTools.
  • Include a Web App Manifest with at least icons of 192x192 and 512x512 pixels, a short name, and a start URL.
  • Add an apple-touch-icon link tag pointing to a 180x180 image for iOS splash screen support.
  • Set the theme-color meta tag to match the manifest theme_color for consistent branding during startup.
  • Verify offline behavior by checking the Offline checkbox in DevTools and reloading the page.
  • Test push notifications manually using the Service Worker panel's Push button in DevTools.

Lighthouse audits are a baseline, not a destination. Passing all 14 PWA audits means your application is technically installable and has minimal offline support. It does not mean your offline experience is well-designed, your caching strategy is optimal, or your push notification flow is user-friendly. Use Lighthouse as the first check in your quality process, then invest in the user experience aspects that automated audits cannot measure.

Putting It All Together

Building a production-grade PWA requires coordinating many moving parts. The service worker must be registered, versioned, and updated without breaking the user experience. Caching strategies must be chosen per resource type and tested under real network conditions. The manifest must be configured for cross-platform compatibility. Push notifications must be permission-gated and user-respecting. Background sync must be idempotent and resilient. IndexedDB schemas must be versioned and migrated.

Start simple. Implement the app shell first with a single Cache First strategy for your static assets. Add Network First for your API routes once the shell works reliably offline. Introduce Stale-While-Revalidate for media and third-party resources. Add push notifications after you have analytics showing that users return to your application at least three times — this is the minimum behavioral signal that push will add value rather than annoyance. Use Workbox from day one because retrofitting it into an existing hand-rolled service worker is more work than starting with it.

The web platform capabilities that make PWAs possible are still evolving. File System Access, WebUSB, Web Bluetooth, and the Async Clipboard API continue to close the gap with native platforms. The adoption of PWA features by iOS, while slow, is progressing — Safari added support for push notification-like features through the Remote Notifications API in recent beta releases. The direction is clear: the web is becoming a first-class application platform, and the skills required to build PWAs today will only become more valuable as the platform matures.