Skip to content

PWA Production Checklist

This is a release checklist for Progressive Web Apps: every item is a concrete, verifiable task grouped by area, with a one-line reason and a link to the page that explains the mechanism. It covers the whole surface that breaks in production, from response headers and the manifest to service worker updates, offline behavior, storage eviction, push delivery, platform quirks, security, testing, monitoring and store packaging. Work through it before the first launch, then re-run the sections that a release touches, because most PWA failures are regressions introduced by a hosting, CDN or build change rather than by the feature being shipped.

Key takeaways

  • The service worker script is the single most dangerous file you deploy: it must be served with Cache-Control: no-cache, a JavaScript MIME type, at a URL that never changes, and you must have a tested kill switch before you need one.
  • Installability is no longer a gate you pass once. Chromium needs a small set of manifest members; Safari 26 and later lets users install anything. What you actually ship is the quality of the installed experience: identity, icons, offline launch and updates.
  • Every cache write needs a policy: which responses, how long, how many, and when it is deleted. Unbounded runtime caches and opaque responses are the most common cause of quota errors.
  • Offline is a UX feature. Precache an offline page, give every route a fallback, and test by actually cutting the network on real devices.
  • Treat iOS as its own platform: Home Screen apps have separate storage, push only works after installation, and apple-touch-icon overrides manifest icons.
  • Ship observability with the app: service worker version, error reporting from the worker, cache and quota telemetry, and Core Web Vitals from real users.

How to use this checklist

Each item is written so that someone other than the author can verify it, usually with DevTools, curl or an automated test. Items marked Blocker make the app broken, insecure or unrecoverable if skipped; the rest improve quality or resilience. Links point to the page that explains why; this page only tells you what to check.

Copy the Markdown into your issue tracker or pull-request template, delete the sections that don't apply (for example push or store packaging), and keep the rest as a release gate. The automated preflight script at the end checks roughly a quarter of the items on every deploy.

flowchart LR
    A["Build"] --> B["Automated preflight<br/>(headers, manifest, icons, sw.js)"]
    B --> C["Automated tests<br/>(offline, update, install criteria)"]
    C --> D["Manual device matrix<br/>(iOS, Android, desktop)"]
    D --> E["Staged rollout<br/>with monitoring"]
    E --> F["Full release"]
    E -->|"Error spike"| K["Kill switch or rollback"]

HTTPS, TLS and response headers

A PWA's capabilities only exist in a secure context, and most of its update logic depends on HTTP caching headers being exactly right.

  • Blocker: every page, asset, API and the manifest is served over HTTPS with a valid certificate. Service workers, push, and most capabilities require a secure context; localhost is the only exception. See Core Building Blocks.
  • HTTP redirects to HTTPS with a 301 or 308 on every host and path, including the apex domain and www. Users arriving from old links must never load an insecure page that could inject a different service worker registration.
  • Strict-Transport-Security is sent (for example max-age=63072000; includeSubDomains) so returning users never make a plain-HTTP request. Consider HSTS preloading once every subdomain is HTTPS.
  • No mixed content. Scripts, frames and fetch() calls to http: URLs are blocked in secure pages, including requests your service worker makes. Check the console on every template.
  • Certificate renewal is automated and monitored. An expired certificate stops update checks for the worker; existing users keep running the old worker until it is fixed.
  • HTTP/2 or HTTP/3 is enabled at the edge. Precaching makes many parallel requests during install; HTTP/1.1 connection limits make installs slow and fragile on mobile networks.
  • Text resources are compressed (Brotli or gzip) and Content-Encoding is correct. The Cache API stores the decoded body, so compression helps the first download, not cache size.
  • Blocker: sw.js is served with Cache-Control: no-cache (or max-age=0) and a JavaScript Content-Type (text/javascript). The update check bypasses the HTTP cache by default, but CDNs and proxies in between don't. See Updating Service Workers.
  • sw.js is never redirected. A redirect on the worker script fails registration and update checks. curl -sI https://example.com/sw.js must return 200 with no Location header.
  • Hashed static assets are immutable: Cache-Control: public, max-age=31536000, immutable for files whose name contains a content hash. See HTTP Caching & Service Workers.
  • HTML documents revalidate (Cache-Control: no-cache or a short max-age), so an online user never gets stale HTML that references deleted hashed assets.
  • The manifest has a short or revalidated lifetime and Content-Type: application/manifest+json. Browsers re-fetch it to detect updates.
  • Personalized responses carry Cache-Control: private (or no-store for secrets) and correct Vary headers, so shared caches never serve one user's data to another.
  • X-Content-Type-Options: nosniff is set, so a mis-typed upload can never be executed as a script.
  • Referrer-Policy is set deliberately (for example strict-origin-when-cross-origin) so launch URLs with parameters don't leak to third parties.
  • Permissions-Policy disables powerful features you don't use (for example camera=(), microphone=(), geolocation=()), which also limits what an injected script could request. See Permissions.
  • CORS headers are correct on cross-origin assets you intend to cache (fonts, images from a CDN). Without CORS they are opaque responses, which you can't inspect and which count heavily against quota.
  • Error pages return real error status codes. A soft-404 (200 with an error page) will be cached by your service worker as if it were content.

The complete header set, as an nginx example. nginx's add_header has a trap: a location block that sets any add_header stops inheriting all add_header directives from the server block, so the security headers would silently disappear from exactly the responses that set Cache-Control. Keeping them in an include file and including it in every location avoids that.

snippets/security-headers.conf
# Included in every location that sets its own add_header (see below).
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
nginx.conf (excerpt)
server {
    listen 443 ssl;
    http2 on;                       # nginx 1.25.1+; older versions use "listen 443 ssl http2;"
    server_name app.example.com;

    include snippets/security-headers.conf;

    # The service worker: always revalidated, never redirected, JavaScript MIME type.
    location = /sw.js {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache" always;
        types { text/javascript js; }
        try_files $uri =404;        # never fall through to the SPA's index.html
    }

    # The manifest: revalidated so manifest updates are seen quickly.
    location = /app.webmanifest {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache" always;
        types { application/manifest+json webmanifest; }
        try_files $uri =404;
    }

    # Content-hashed build output: cache for a year.
    location /assets/ {
        include snippets/security-headers.conf;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;        # a missing chunk must be a real 404, not index.html
    }

    # HTML: always revalidate. The SPA fallback applies only here.
    location / {
        include snippets/security-headers.conf;
        add_header Cache-Control "no-cache" always;
        try_files $uri /index.html;
    }
}

Verify the effective headers at the edge, not at the origin: curl -sI https://app.example.com/sw.js from outside your network shows what a CDN actually forwards. Many CDNs apply their own default TTL to .js files unless the origin's Cache-Control is honored or a rule excludes /sw.js.

Web app manifest

  • Blocker: every installable page links the manifest with <link rel="manifest" href="/app.webmanifest"> in the <head>. Pages without the link aren't installable from that page. See Web App Manifest.
  • The manifest is valid JSON, checked in CI. A trailing comma yields an empty manifest and silently disables installation.
  • Blocker: id is set explicitly and will never change. It defines which app an installed icon belongs to. If you already shipped without id, copy the Computed App Id from DevTools into it. See App Identity & Updates.
  • start_url is set, same-origin, inside scope, and works offline. Launching the installed app with no network must not show the browser's error page.
  • start_url carries a source parameter (for example /?source=pwa) for analytics, and never a user identifier.
  • scope is set and ends in /. Scope matching is string-prefix based: /app also matches /apple.
  • name and short_name are set. short_name is what users see under the icon; test it on a small phone for truncation.
  • display is standalone (or minimal-ui / fullscreen when justified). Enhanced modes such as window-controls-overlay go in display_override, never in display. See Display Modes.
  • theme_color and background_color are set, and background_color matches the CSS body background so there is no flash between the Android splash screen and first paint.
  • description and screenshots are set for Chrome's richer install dialog: at least one wide screenshot for desktop and one narrow for mobile, each with a label. See Rich Install UI.
  • shortcuts point to in-scope URLs that work offline, with the most important first; Chrome for Android uses only the first four. See App Shortcuts.
  • prefer_related_applications is absent or false, unless you deliberately want Android users sent to a native app. true stops Chromium's install promotion.
  • Integration members are declared only if the feature is implemented: share_target, file_handlers, protocol_handlers, launch_handler. See Advanced & Integration Members.
  • All navigational URLs in the manifest are within scope. Out-of-scope shortcut, share-target or handler URLs are dropped with only a console warning.
  • The manifest is served from the app's origin, not a CDN; relative URLs in a cross-origin manifest resolve against the CDN origin and fail the same-origin checks.
  • CSP manifest-src (or default-src) allows the manifest URL. A blocked manifest fetch looks exactly like "no manifest".
  • DevTools → Application → Manifest shows no errors or warnings in Chrome or Edge.
  • Legacy vendor members are removed (gcm_sender_id, IE/EdgeHTML msapplication-* tags).

Every member, its type and support is summarized in the Manifest Cheat Sheet.

Icons

  • Blocker: at least one any-purpose icon of 144 px or more in PNG, SVG or WebP, with sizes declared. Chromium's installability check fails with maskable-only icons or undeclared sizes. See Installability Criteria.
  • 192 × 192 and 512 × 512 PNG any icons are present; Android's splash screen and WebAPK minting look best with a 512 px source.
  • Separate maskable icons (192 and 512) with an opaque, full-bleed background and the logo inside the central 80 % safe circle. Never ship "purpose": "any maskable" on one image. See Icons & Maskable Icons.
  • Maskable icons are previewed in a maskable-icon previewer or DevTools' Show only the minimum safe area for maskable icons option.
  • A monochrome icon exists if you want a proper glyph on platforms that tint icons.
  • <link rel="apple-touch-icon"> points to a 180 × 180 opaque PNG without rounded corners. Safari uses it instead of manifest icons.
  • Favicons (favicon.ico with 16/32/48 px and an SVG icon) are present for tabs and bookmarks.
  • Icons are served with correct Content-Type and are reachable without cookies; the browser downloads them outside your page's session.
  • Icon URLs change when the artwork changes (content hash in the filename). Desktop Chromium 144 and later compares icon URLs, not bytes, so replacing a file in place never reaches installed apps.
  • No icon exceeds 1024 px in the primary set; desktop Chromium ignores larger icons for the installability check.
  • Shortcut icons (96 × 96) exist for every shortcut, so the menu isn't a column of blank squares.

Service worker registration and scope

  • Blocker: the worker script URL is stable (/sw.js), with the version written inside the file. Versioned worker URLs (/sw-v2.js) strand users whose cached HTML registers the old URL. See Pitfalls & Anti-Patterns.
  • The worker is at the root (or Service-Worker-Allowed is sent deliberately) so its scope covers every page the app navigates to. See Registration & Scope.
  • Registration is feature-detected ("serviceWorker" in navigator) and runs after the page's critical work, typically after load, so installation doesn't compete with first render.
  • Registration failures are reported to your error tracker. register() rejects on a 404, a bad MIME type, a syntax error or a scope violation, and nothing else tells you.
  • All event listeners are registered synchronously at the top level of the worker. Listeners added after an await miss events after the worker restarts.
  • No state lives only in global variables. The browser stops idle workers after roughly 30 seconds; everything must survive a restart. See Lifecycle.
  • importScripts() and module imports are same-origin and versioned together with the worker, so a partial deploy can't mix versions.
  • The worker registers in production only, or the development server serves a no-op worker, so a developer's stale worker never masks changes.
  • Only one registration exists per scope. Check DevTools → Application → Service workers for leftovers from older paths or frameworks.
  • A fetch handler exists only if it does something. Chromium recognizes a literally empty listener as a no-op and skips it (with the console warning "Fetch event handler is recognized as no-op"), but any handler with a body, even a pass-through that never calls respondWith(), costs a worker start-up on navigations and gives nothing back. Other engines don't skip empty handlers.
  • Navigation preload is enabled if the worker handles navigations with a network-first strategy, and the preload response is always consumed. See Navigation Preload.
  • No page awaits navigator.serviceWorker.ready without a timeout; it never resolves if registration fails.
src/register-sw.js
// Registers the service worker once the page has loaded, and reports failures.
export function registerServiceWorker({ onUpdateReady, onError } = {}) {
  if (!("serviceWorker" in navigator)) return; // Unsupported or insecure context.

  window.addEventListener("load", async () => {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
        updateViaCache: "none", // Also bypass the HTTP cache for importScripts().
      });

      // A worker already waiting from an earlier visit.
      if (registration.waiting && navigator.serviceWorker.controller) {
        onUpdateReady?.(registration);
      }

      registration.addEventListener("updatefound", () => {
        const worker = registration.installing;
        worker?.addEventListener("statechange", () => {
          // "installed" + an existing controller = an update, not a first install.
          if (worker.state === "installed" && navigator.serviceWorker.controller) {
            onUpdateReady?.(registration);
          }
        });
      });

      // Long-lived tabs and installed apps rarely navigate: check hourly and on focus.
      const check = () => registration.update().catch(() => {});
      setInterval(check, 60 * 60 * 1000);
      document.addEventListener("visibilitychange", () => {
        if (document.visibilityState === "visible") check();
      });
    } catch (error) {
      // 404, wrong MIME type, syntax error, scope violation, or storage disabled.
      onError?.(error);
      console.error("Service worker registration failed", error);
    }
  });
}

Caching per resource type

Every resource type needs its own strategy, cache name and expiry policy. Write the table for your app before writing the worker.

Resource Strategy Cache Expiry and limits Notes
App shell (HTML template, core CSS/JS) Precache app-precache-v{n} Replaced per version; old caches deleted in activate Keep it small; enforce a byte budget in CI
Hashed static assets (JS chunks, CSS) Cache first app-static Max entries (for example 100) or age Immutable, so cache first is safe
HTML navigations Network first with timeout, fallback to cache, then offline page app-pages Max entries (for example 50) Use navigation preload
API reads (JSON) Network first or stale-while-revalidate app-api Short max age; never cache authenticated data without a sign-out purge Consider IndexedDB instead of the Cache API
API writes (POST, PUT, DELETE) Network only, queue on failure — — Background Sync on Chromium, retry on open elsewhere
Images (same-origin) Cache first or stale-while-revalidate app-images Max entries and max age Largest consumer of quota
Third-party images, fonts Stale-while-revalidate, CORS only app-cdn Max entries Avoid opaque responses
Fonts (self-hosted) Cache first app-fonts Long Precache the one or two used above the fold
Video and audio Network only, or explicit offline download app-media User-controlled Must answer Range requests from cache
Analytics, beacons Network only (queue if needed) — — Never cache
Auth, sign-out, payment endpoints Network only — — Exclude from the fetch handler entirely
  • Every cache name has a namespace and version, and activate deletes only your own outdated caches. See Cache Storage API.
  • Every runtime cache has a size or age limit. Unbounded caches eventually cause QuotaExceededError.
  • Blocker: only cacheable responses are stored. One gate rejects opaque, non-ok, 206 Partial Content, Vary: * and Cache-Control: no-store responses before cache.put(). See Pitfalls & Anti-Patterns.
  • The precache is the app shell only, within a byte budget enforced in the build. Precaching the whole site costs every user that download on first visit. See Precaching & Runtime Caching.
  • Cache keys are normalized: tracking parameters (utm_*, fbclid, gclid) are stripped or ignoreSearch is used deliberately.
  • Precached navigations are stored under their final URL, never as redirected responses, which browsers refuse for navigations.
  • respondWith() is called synchronously within the event dispatch and always resolves with a Response.
  • Media routes support Range requests from cached full responses, or bypass the worker.
  • Strategies are chosen from the table above, not copied from a tutorial. See Caching Strategies and, if you use it, Workbox Fundamentals.
  • The Static Routing API is considered for routes that should never start the worker. See Static Routing API.

Update flow

  • Blocker: a new deploy produces a byte-different sw.js. The version constant or precache manifest must change whenever any precached asset changes, or users never receive the update.
  • The activation pattern is a deliberate choice: wait (default), prompt the user, activate on next navigation, or skipWaiting() with compatible assets. See Updating Service Workers.
  • If you call skipWaiting(), old hashed assets stay deployed for at least as long as a tab might stay open (days, not minutes). Otherwise lazy-loaded chunks 404 in pages loaded by the old version.
  • Failed dynamic imports trigger one controlled reload, not an infinite loop.
  • controllerchange reloads are guarded: not on first install, only once, and only in the tab that asked.
  • Long-lived tabs and installed apps call registration.update() periodically or on visibilitychange; SPAs don't navigate, so the browser rarely checks on its own.
  • The page-to-worker message protocol is versioned, so an old page talking to a new worker (or the reverse) fails gracefully. See Messaging & the Clients API.
  • IndexedDB schema migrations are forward-only and tested from every version still in the wild, in onupgradeneeded. See IndexedDB.
  • The update prompt is accessible (focusable, announced, dismissible) and doesn't interrupt a user mid-task.
  • A rollback is a redeploy of the previous build with the current schema version, rehearsed at least once in staging.
  • Manifest changes are planned separately: name and icon changes need user approval on desktop Chromium and a new WebAPK on Android. See App Identity & Updates.

Offline fallback

  • Blocker: an offline page is precached and served for any navigation that fails with no cached copy. Installed apps without one show the browser's generic offline page, which looks broken. See Offline UX & Fallbacks.
  • start_url and every shortcut URL load offline. Launching the installed app with the network off is the first thing users try.
  • The offline page is self-contained: inline CSS, no external fonts, and a retry button.
  • Images have an offline placeholder (a precached SVG), so layouts don't collapse.
  • API failures produce a clear in-app state, not a spinner that never ends. Time out network requests (for example 3 to 5 seconds on navigations) before falling back.
  • Offline and online state is communicated with a visible, announced indicator. navigator.onLine only says whether there is a network interface; confirm connectivity by the outcome of real requests.
  • Writes made offline are queued durably in IndexedDB and replayed: Background Sync where available (Chromium), and on app start or online everywhere else. See Background Sync and Offline-First Data & Sync.
  • Conflicts are handled when a queued write reaches a server whose data changed; the rule (last write wins, merge or ask) is documented.
  • Stale cached data is labeled ("Last updated 3 hours ago") wherever it could mislead.
  • Offline has been tested on a real device in airplane mode, not only with DevTools' offline checkbox, which doesn't simulate captive portals or "lie-fi".
sw.js (navigation fallback excerpt)
const OFFLINE_URL = "/offline.html";
const PRECACHE = "app-precache-v42";

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(PRECACHE).then((cache) => cache.addAll([OFFLINE_URL, "/offline.svg"])),
  );
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Navigation preload starts the network request while the worker boots.
      if (self.registration.navigationPreload) {
        await self.registration.navigationPreload.enable();
      }
    })(),
  );
});

self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return; // Other requests: separate routes.

  event.respondWith(
    (async () => {
      try {
        // Use the preload response if the browser started one.
        const preloaded = await event.preloadResponse;
        if (preloaded) return preloaded;
        return await fetch(event.request);
      } catch {
        // Network failed: try a cached copy of this page, then the offline page.
        const cached = await caches.match(event.request, { ignoreSearch: true });
        return cached ?? (await caches.match(OFFLINE_URL, { cacheName: PRECACHE }))
          ?? new Response("Offline", { status: 503, headers: { "Content-Type": "text/plain" } });
      }
    })(),
  );
});

Storage and quota

  • Usage is measured with navigator.storage.estimate() and reported to telemetry. See Storage Quotas & Persistence.
  • QuotaExceededError is handled on every write path (Cache API, IndexedDB, OPFS) by evicting your own least important data and retrying once.
  • navigator.storage.persist() is requested only when justified (for example after the user saves offline content), and the result is recorded.
  • Safari's 7-day cap is accounted for. In Safari browser tabs, script-writable storage (including the service worker registration and caches) is deleted after seven days of Safari use without user interaction with the site. Home Screen web apps are exempt.
  • The server is the source of truth. No user data exists only in browser storage for longer than it takes to sync.
  • Home Screen apps on iOS are treated as separate storage: a user who installs the app starts with empty IndexedDB and caches, and only cookies are copied (Safari 17.2 and later on iOS).
  • Private browsing works in degraded mode. Storage may be in memory, smaller, or discarded at the end of the session; the app must not crash when a write fails.
  • IndexedDB versionchange and blocked events are handled, so an old tab can't block a schema upgrade forever.
  • Large files use OPFS or streaming, not giant IndexedDB blobs held in memory. See Origin Private File System.
  • Sign-out deletes per-user data: user-specific caches, IndexedDB databases and OPFS files.
  • Storage Buckets are considered on Chromium to separate evictable caches from important drafts.

Push notifications

Skip this section if the app doesn't use push.

  • VAPID keys are generated once, stored as secrets and never rotated casually: rotating the key invalidates every existing subscription. See The Web Push Protocol.
  • Permission is requested only after a user gesture and a clear in-app explanation, never on page load. Safari and Firefox require a user gesture, and Chromium quiets sites whose prompts are routinely denied. See Push Notifications.
  • subscribe() uses userVisibleOnly: true and the application server key as a Uint8Array or base64url string.
  • Blocker: every push event shows a notification inside event.waitUntil(). Silent pushes are penalized, and Safari revokes the subscription after repeated violations. See Web Push on iOS & Safari.
  • Subscriptions are stored server-side with the user and device, and re-sent on every app start so the server always has the current endpoint.
  • 404 and 410 responses from the push service delete the subscription on your server; they mean it has expired or was revoked (RFC 8030).
  • Messages set TTL (and Urgency where it matters), so stale notifications are dropped instead of delivered hours late.
  • Payloads fit the 4,096-byte limit. RFC 8030 only requires push services to accept 4,096 bytes of encrypted payload; with aes128gcm encryption (RFC 8291) and a single record that leaves 3,993 bytes of plaintext. Send an ID and fetch the details if you need more.
  • notificationclick focuses an existing window when one is open and navigates it, instead of always opening a new one. See Notifications API.
  • Notification tag values collapse related notifications so a chat doesn't produce 40 separate alerts.
  • On iOS and iPadOS, push is offered only in the installed Home Screen app (16.4 and later). In a Safari tab, show installation instructions instead.
  • Declarative Web Push is evaluated for Safari (iOS and iPadOS 18.4 and later, Safari 18.5 and later on macOS), which displays notifications from a JSON payload without running your service worker and is exempt from the silent-push penalty.
  • The app badge is cleared when the user reads the content. See Badging API.
  • Users can unsubscribe in-app, and the server honors it immediately.

Installability per platform

Platform How users install What you must provide Custom install button?
Chrome and Edge, desktop Address-bar install icon, menu Manifest meeting Chromium's criteria Yes, via beforeinstallprompt
Chrome for Android Install prompt, menu → Add to home screen / Install Same; WebAPK is minted when Google Play services is present Yes, via beforeinstallprompt
Samsung Internet Install icon in the URL bar Same criteria Yes
Safari on iOS and iPadOS 26+ Share → Add to Home Screen, Open as Web App on by default Nothing required; manifest and apple-touch-icon improve the result No; show instructions
Safari on macOS 14+ File → Add to Dock Nothing required No; show instructions
Firefox for Android Menu → Add to Home screen Manifest improves name and icon No
Firefox on Windows (143+) Pin the site to the taskbar as a web app Nothing required No
  • Chromium reports no installability errors: DevTools → Application → Manifest → Installability. See Installability Criteria.
  • The beforeinstallprompt listener is attached synchronously at startup; a listener attached after an await misses the event.
  • prompt() is called only from a user gesture, once per event, and the outcome is recorded. See Install Prompts & Custom UI.
  • The install button is hidden when already installed (display-mode: standalone or appinstalled fired) and when the browser offers no prompt.
  • iOS users get accurate Share-sheet instructions, shown only in Safari tabs on iOS and iPadOS, not in the installed app.
  • Installed launches are detectable with matchMedia("(display-mode: standalone)") and, on iOS, navigator.standalone. See Detecting Installed Apps.
  • The app works in every display mode a user might end up with: a standalone window with no back button or URL bar needs in-app navigation and a way to share the current URL. See App-Like UX Patterns.
  • Out-of-scope links behave sensibly: authentication flows on other origins use a popup or stay in scope.
  • Android WebAPK behavior is verified on a device with Google Play services (link capturing, splash screen, app drawer entry), and the shortcut fallback on one without. See Android.
  • Desktop integration is verified on Windows and macOS: window title, taskbar or Dock icon, uninstall path. See Desktop Platforms.

iOS and iPadOS specifics

  • <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png"> (180 × 180, opaque) is present on every page.
  • <meta name="apple-mobile-web-app-title"> matches short_name, or is omitted.
  • <meta name="apple-mobile-web-app-status-bar-style"> is set deliberately: default, black or black-translucent. With black-translucent, content runs under the status bar, so pad with env(safe-area-inset-top).
  • The viewport includes viewport-fit=cover when content extends under the status bar or home indicator, and every edge uses env(safe-area-inset-*).
  • apple-touch-startup-image splash screens exist for the device sizes you care about, or you accept a blank launch screen. Safari doesn't read background_color and generates no splash screen from the manifest. See Splash Screens & Theming.
  • Two <meta name="theme-color"> tags with media for light and dark are present; Safari 26 uses theme color only for installed web apps.
  • The legacy apple-mobile-web-app-capable tag is not relied on. From iOS 26, every Home Screen site opens as a web app by default.
  • The installed app has been tested separately from Safari: separate storage, separate cookies after install, separate notification permission.
  • Web Inspector has been attached to the installed app on a real device to verify the worker, caches and push. See iOS & iPadOS.
  • Features unavailable on iOS are feature-detected (Background Sync, Periodic Background Sync, Background Fetch, beforeinstallprompt, most device APIs), and the UI doesn't offer them.

The full equivalence between these tags and manifest members is in the Manifest Cheat Sheet.

Performance budgets

Set budgets as numbers in CI, not as intentions. The Core Web Vitals thresholds below are Google's published "good" thresholds, assessed at the 75th percentile of page loads, separately for mobile and desktop; the other rows are budgets you choose for your app.

Metric Budget Where it is enforced
Largest Contentful Paint (LCP) ≤ 2.5 s at p75 Field data (RUM, CrUX)
Interaction to Next Paint (INP) ≤ 200 ms at p75 Field data
Cumulative Layout Shift (CLS) ≤ 0.1 at p75 Field data
Precache size (transfer) Your number, for example the size of one typical page view Build step fails above it
Critical-path JavaScript (compressed) Your number, per route Bundle analyzer in CI
Service worker script size Small enough to parse quickly on low-end phones; split rarely used code into lazily imported modules Build step
Repeat-visit navigation served from cache Faster than the network path at p75 RUM segmented by controller present
  • Core Web Vitals are collected from real users with the web-vitals library or equivalent, segmented by installed vs browser tab and by service worker version. See Core Web Vitals and Measuring Performance.
  • The worker doesn't slow the first visit. Registration waits for load, and precaching starts after the page is interactive.
  • The worker doesn't slow repeat navigations. Compare navigation timing with and without a controller; enable navigation preload or the Static Routing API for network-first routes, because a worker boot on a cold start adds latency before any network request.
  • The app shell renders without waiting for data, and skeletons match final layout to keep CLS low. See App Shell Model.
  • Critical resources are preloaded and fonts use font-display, and the LCP image is discoverable in the HTML. See Loading Performance.
  • Long tasks are broken up and heavy work runs in a worker, to keep INP under budget in long-lived installed sessions. See Runtime Performance.
  • Memory is stable over hours. Installed apps stay open far longer than tabs; profile a long session for leaks.
  • Budgets fail the build, rather than producing a report nobody reads.

Accessibility

  • The app meets WCAG 2.2 level AA, the current W3C Recommendation. Legal requirements in many markets reference it or its predecessors. See Accessibility.
  • Zoom is not disabled. No user-scalable=no or maximum-scale=1 in the viewport meta tag; standalone mode doesn't change this requirement.
  • Orientation isn't locked in the manifest unless it is essential (success criterion 1.3.4, Orientation).
  • Touch targets are at least 24 × 24 CSS pixels (success criterion 2.5.8, Target Size (Minimum)), and larger for primary actions.
  • Focus is never hidden under sticky headers, bottom navigation or a custom title bar (success criterion 2.4.11, Focus Not Obscured (Minimum)).
  • Offline, update and sync status changes are announced through an aria-live region, not only shown with color or an icon.
  • The install button, update prompt and permission explainers are keyboard-operable and have accessible names.
  • In-app back navigation exists in standalone mode, since there is no browser back button on desktop windows or iOS.
  • prefers-reduced-motion is respected in view transitions and splash animations. See View Transitions.
  • theme_color contrasts with the system's title-bar and status-bar text in both light and dark themes.
  • Screenshots and icons have labels: label on every manifest screenshot, alternative text in store listings.
  • Window Controls Overlay layouts keep the title-bar region usable with a keyboard and at 200 % zoom. See Window Controls Overlay.
  • Notifications make sense without images and have a descriptive title and body.

Security: CSP and service worker security

  • Blocker: a Content Security Policy is deployed, with at least default-src 'self', script-src without 'unsafe-inline' (use nonces or hashes), object-src 'none' and base-uri 'none'. See Content Security Policy.
  • worker-src 'self' is set explicitly. Without it, the policy for workers (including service workers) falls back to child-src, then script-src, then default-src.
  • manifest-src 'self' (or your manifest origin) is set.
  • connect-src doesn't list push service origins. The browser's push client, not your page or worker, talks to the push service; only your own endpoint that stores subscriptions must be allowed. Your application server sends messages from the back end.
  • The CSP of the service worker script response is correct. A worker's own CSP comes from the headers on sw.js, not from the page, and it governs importScripts() and fetch() from the worker.
  • Blocker: no user can upload files that are served from your origin with a JavaScript MIME type at a path that could be registered as a service worker. Serve user content from a separate origin. See Service Worker Security.
  • Service-Worker-Allowed is not sent, or is limited to the exact scope you need.
  • Authorization is enforced on the server. The service worker is client code and can be modified by the user; it is never a security boundary.
  • Authenticated responses are not cached by the worker, or are cached per user and deleted on sign-out.
  • Sign-out sends Clear-Site-Data: "cache", "cookies", "storage" from a response the worker doesn't intercept, when appropriate for your app. Remember that "storage" also unregisters the service worker and deletes offline drafts.
  • Third-party scripts are minimized and never imported into the service worker; a compromised third-party script in the worker controls every request.
  • Messages from postMessage() are validated in both directions, including event.origin for cross-window messages and the source client for worker messages.
  • Push payloads contain no secrets; they may be displayed on a lock screen.
  • VAPID private keys live in a secret manager, not in the repository or client bundle.
  • Permissions are requested in context and just in time, never all at startup. See Permissions.
  • Privacy review is done: no user identifiers in start_url or shortcut URLs, no fingerprinting through installed-app detection, storage partitioning understood for embedded contexts. See Privacy & Storage Partitioning.
Response headers for HTML (example)
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{RANDOM}'; style-src 'self'; img-src 'self' data: https://images.example-cdn.com; connect-src 'self' https://api.example.com; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests

Analytics

  • Installed launches are counted through the start_url source parameter and shortcut parameters. See Analytics for PWAs.
  • Display mode is a dimension on every hit: standalone, minimal-ui, fullscreen, window-controls-overlay or browser, from matchMedia("(display-mode: …)").
  • Install funnel events are tracked: beforeinstallprompt received, custom button shown, prompt() outcome, appinstalled, and iOS instruction views.
  • Store-installed traffic is attributed: Microsoft Store installs launch with the referrer app-info://platform/microsoft-store in Edge, and Trusted Web Activity launches with an android-app:// referrer carrying the package name.
  • Offline events are queued and sent later, so offline usage isn't invisible. Analytics requests are network-only, never cached.
  • The service worker version is a dimension, so you can see how fast a release reaches users and compare error rates per version.
  • Push metrics are tracked: permission prompt shown, granted, denied; subscriptions; delivery failures by status code; notificationclick and close.
  • Consent requirements apply to the service worker too. A worker that sends telemetry must respect the same consent state as the page.

Testing matrix

Test the scenarios on the left in each environment. ⚠️ marks environments where the scenario only partly applies; ❌ where the feature doesn't exist and the test is that the UI degrades cleanly.

Scenario Chrome desktop Edge Windows Chrome Android Samsung Internet Safari iOS tab iOS Home Screen app Safari macOS / Dock app Firefox desktop Firefox Android
First visit, worker installs ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅
Offline launch and navigation ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅
Update flow (old tab + new deploy) ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅
Install from custom button ✅ ✅ ✅ ✅ ❌ ❌ ❌ ❌ ❌
Install from browser UI ✅ ✅ ✅ ✅ ✅ — ✅ ⚠️ ✅
Launch as standalone, icons, splash ✅ ✅ ✅ ✅ — ✅ ✅ ⚠️ ⚠️
Push subscribe, receive, click ✅ ✅ ✅ ✅ ❌ ✅ ✅ ✅ ✅
Badging ✅ ✅ ❌ ❌ ❌ ✅ ✅ ❌ ❌
Share target, shortcuts ⚠️ ⚠️ ✅ ✅ ❌ ❌ ⚠️ ❌ ❌
Background Sync replay ✅ ✅ ✅ ✅ ❌ ❌ ❌ ❌ ❌
Storage eviction and quota errors ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅

Support data as of September 2026; check each API's page and caniuse.com for current data. Notes: shortcuts work on desktop Chromium and Safari 17.4+ on macOS, while share targets register on Android and ChromeOS only. Firefox on Windows can pin sites to the taskbar (Firefox 143+) but has no manifest-based installation. Badging works in installed apps on desktop Chromium (Windows, macOS, ChromeOS) and in Home Screen and Dock web apps on Apple platforms.

  • Unit tests cover the worker's routing and cache policies with a service worker test harness or by testing pure functions extracted from the worker. See Automated Testing.
  • End-to-end tests run with the worker enabled (Playwright or Puppeteer), including offline (context.setOffline(true)) and an update from one build to the next.
  • Installability is asserted in CI through the Chrome DevTools Protocol (Page.getInstallabilityErrors). See Installability Criteria.
  • Auditing doesn't rely on a Lighthouse PWA score. Lighthouse 12 removed the PWA category in 2024; use this checklist, DevTools and targeted tests instead. See Lighthouse & Auditing.
  • Real devices are in the loop: at least one low-end Android phone, one current iPhone with the app installed, and each desktop OS you support. See Browser DevTools.
  • Throttled and flaky networks are tested, not just online and offline: slow 3G profiles, a captive portal, and a server returning 500 for the API.
  • Upgrade paths are tested from each version in the wild, including a user who hasn't visited for months.
  • A clean-profile test runs before every release, since developers' browsers always have state that users don't.

Monitoring and kill switch

  • Errors in the service worker are reported. Listen for error and unhandledrejection in the worker global scope and send them with the worker version.
  • Registration and update failures are reported from the page, since failed soft updates are silent.
  • Version adoption is visible: the percentage of active users on each worker version over time.
  • Cache behavior is visible: hit ratio per route, cache sizes from storage.estimate(), QuotaExceededError counts.
  • Push health is visible: send success rate, 404/410 rate, and subscription churn.
  • Blocker: a tested kill-switch worker is in the repository, ready to deploy at the same URL as the real worker. It deletes your caches and unregisters itself or becomes a pass-through. See Updating Service Workers and Pitfalls & Anti-Patterns.
  • The kill switch has been rehearsed in staging, including with a tab that was loaded by the broken version.
  • A Clear-Site-Data emergency configuration is documented for sw.js, with the warning that it also deletes user data.
  • A remote configuration flag can disable risky worker features (for example a caching route) without a full deploy; the worker fetches it network-only, with a short timeout and a safe default.
  • Rollouts are staged (a canary percentage or a subset of routes) with alerts on error rates and Core Web Vitals regressions.
  • Alert thresholds and an on-call runbook exist for "users stuck on an old version", "blank page from cache" and "push delivery collapsed".
sw.js (telemetry excerpt)
const SW_VERSION = "2026.09.25-1";

// Report worker errors. `error` and `unhandledrejection` are not ExtendableEvents, so
// there is no waitUntil(): the worker may be stopped before the request completes.
// `keepalive` lets the request outlive the worker; losing an occasional report is fine.
function report(kind, detail) {
  const body = JSON.stringify({ kind, detail, version: SW_VERSION, time: Date.now() });
  return fetch("/telemetry/sw", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
    keepalive: true,
  }).catch(() => {
    // Offline or blocked: dropping an error report is acceptable.
  });
}

self.addEventListener("error", (event) => {
  report("error", { message: event.message, filename: event.filename, line: event.lineno });
});

self.addEventListener("unhandledrejection", (event) => {
  const reason = event.reason instanceof Error ? event.reason.message : String(event.reason);
  report("unhandledrejection", { reason });
});

// Answer version queries from pages, so analytics can tag every hit.
self.addEventListener("message", (event) => {
  if (event.data?.type === "GET_VERSION") {
    event.source?.postMessage({ type: "VERSION", version: SW_VERSION });
  }
});

Store packaging

Skip the stores you don't target. Details for each are in Publishing to App Stores.

Google Play (Trusted Web Activity)

  • The app is packaged as a Trusted Web Activity with Bubblewrap (bubblewrap init --manifest=…, bubblewrap build) or PWABuilder. See Trusted Web Activity.
  • Blocker: /.well-known/assetlinks.json lists the SHA-256 fingerprint of the Play App Signing key, not only your upload key. A mismatch shows the browser's URL bar inside the app.
  • The Android project targets the API level Google Play currently requires. From August 31, 2026, new apps and updates must target Android 16 (API level 36).
  • The TWA's fallback behavior is tested on a device without Chrome as the default browser.
  • Play policies are met: privacy policy URL, data safety form, and Play Billing for digital goods where Play's payment policy requires it. See Payments.

Microsoft Store

  • A product name is reserved in Partner Center, and the Package ID, Publisher ID and publisher display name are copied into PWABuilder's Windows packaging options.
  • The generated .msixbundle and .classic.appxbundle are both submitted. See PWABuilder.
  • A new package is submitted whenever the manifest's icons, name or integration members change; Microsoft's documentation notes that manifest data is copied into the Windows package, while ordinary web changes need no resubmission.

Apple App Store

  • A native wrapper is justified. Apple's App Review Guideline 4.2 (Minimum Functionality) expects apps to "elevate it beyond a repackaged website"; plain wrappers are routinely rejected. For most PWAs the Home Screen web app is the iOS distribution channel.

All stores

  • Store listings reuse the manifest's screenshots, description and categories, updated per store taxonomy.
  • An age rating is obtained where the store requires one; set iarc_rating_id only if you hold a real certificate.
  • Store builds are updated from CI so the wrapper doesn't drift from the web app's identity and icons.

Automating the checklist in CI

The script below checks the items that a machine can check against a deployed environment: HTTPS redirect, HSTS, sw.js headers, the manifest link and required members, icon reachability and the offline page. Run it against staging on every deploy and against production after release. It needs Node.js 18 or later (built-in fetch) and no dependencies.

scripts/pwa-preflight.mjs
#!/usr/bin/env node
// Usage: node scripts/pwa-preflight.mjs https://staging.example.com [--offline-url=/offline.html]
// Exits with code 1 when any blocker fails. Warnings are printed but don't fail the run.

const [, , baseArg, ...flags] = process.argv;
if (!baseArg) {
  console.error("Usage: pwa-preflight.mjs <base-url> [--offline-url=/offline.html] [--sw-url=/sw.js]");
  process.exit(2);
}
const base = new URL(baseArg);
const option = (name, fallback) =>
  flags.find((f) => f.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const swPath = option("sw-url", "/sw.js");
const offlinePath = option("offline-url", "/offline.html");

const results = [];
const pass = (msg) => results.push({ level: "pass", msg });
const warn = (msg) => results.push({ level: "warn", msg });
const fail = (msg) => results.push({ level: "fail", msg });

async function get(url, init = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 15_000);
  try {
    return await fetch(url, { redirect: "manual", ...init, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

async function checkHttps() {
  if (base.protocol !== "https:") return fail(`Base URL is not HTTPS: ${base}`);
  const insecure = new URL(base);
  insecure.protocol = "http:";
  try {
    const res = await get(insecure);
    const location = res.headers.get("location") ?? "";
    if ([301, 308].includes(res.status) && location.startsWith("https://")) {
      pass(`HTTP redirects to HTTPS with ${res.status}`);
    } else {
      fail(`HTTP request returned ${res.status} without a permanent HTTPS redirect`);
    }
  } catch (error) {
    warn(`Could not test the HTTP redirect: ${error.message}`);
  }
}

async function checkPage() {
  const res = await get(base);
  if (res.status !== 200) {
    fail(`Start page returned ${res.status}`);
    return null;
  }
  const hsts = res.headers.get("strict-transport-security");
  hsts ? pass("Strict-Transport-Security present") : fail("Strict-Transport-Security missing");

  const csp = res.headers.get("content-security-policy");
  if (!csp) warn("No Content-Security-Policy header on the start page");
  else {
    if (!/worker-src/.test(csp)) warn("CSP has no explicit worker-src");
    if (!/manifest-src/.test(csp) && !/default-src/.test(csp)) warn("CSP has no manifest-src or default-src");
  }

  const html = await res.text();
  const link = html.match(/<link\b[^>]*\brel=["']?manifest["']?[^>]*>/i)?.[0];
  const href = link?.match(/\bhref=["']?([^"'\s>]+)/i)?.[1];
  if (!href) {
    fail('No <link rel="manifest"> on the start page');
    return null;
  }
  pass(`Manifest linked: ${href}`);
  if (!/rel=["']?apple-touch-icon/i.test(html)) warn("No apple-touch-icon link (Safari falls back to manifest icons)");
  if (/user-scalable\s*=\s*no|maximum-scale\s*=\s*1(\.0)?\b/i.test(html)) fail("Viewport disables zoom");
  return new URL(href, base);
}

async function checkManifest(manifestUrl) {
  const res = await get(manifestUrl);
  if (res.status !== 200) return fail(`Manifest returned ${res.status}`);
  const type = res.headers.get("content-type") ?? "";
  if (!/json/.test(type)) warn(`Manifest Content-Type is "${type}", expected application/manifest+json`);

  let manifest;
  try {
    manifest = JSON.parse(await res.text());
  } catch (error) {
    return fail(`Manifest is not valid JSON: ${error.message}`);
  }

  if (typeof manifest.id === "string" && manifest.id) pass(`id: ${manifest.id}`);
  else warn("Manifest has no explicit id (identity falls back to start_url)");
  if (!manifest.name && !manifest.short_name) fail("Manifest needs name or short_name");
  if (typeof manifest.start_url !== "string") fail("Manifest has no start_url");
  if (typeof manifest.scope !== "string") warn("Manifest has no explicit scope");
  else if (!manifest.scope.endsWith("/")) warn(`scope "${manifest.scope}" does not end with /`);

  const displayOk = ["standalone", "fullscreen", "minimal-ui"].includes(manifest.display);
  const overrideFirst = Array.isArray(manifest.display_override) ? manifest.display_override[0] : undefined;
  if (!displayOk && !overrideFirst) fail(`display "${manifest.display}" is not installable in Chromium`);
  if (["window-controls-overlay", "tabbed"].includes(manifest.display)) {
    fail(`"${manifest.display}" is only valid in display_override`);
  }
  if (manifest.prefer_related_applications === true) warn("prefer_related_applications is true");

  const icons = Array.isArray(manifest.icons) ? manifest.icons : [];
  const anyIcon = icons.find((icon) => {
    const purposes = (icon.purpose ?? "any").toLowerCase().split(/\s+/);
    const sizes = (icon.sizes ?? "").toLowerCase().split(/\s+/);
    const bigEnough = sizes.some((s) => s === "any" || Number(s.split("x")[0]) >= 144);
    return purposes.includes("any") && bigEnough;
  });
  anyIcon ? pass(`Installable icon: ${anyIcon.src}`) : fail("No 'any' icon of at least 144 px with sizes");
  if (!icons.some((icon) => (icon.purpose ?? "").includes("maskable"))) warn("No maskable icon");
  if (icons.some((icon) => /any/.test(icon.purpose ?? "") && /maskable/.test(icon.purpose ?? ""))) {
    warn('An icon combines "any maskable"; ship separate files');
  }

  // Every icon must be reachable without cookies and served as an image.
  await Promise.all(
    icons.map(async (icon) => {
      const url = new URL(icon.src, manifestUrl);
      try {
        const r = await get(url, { method: "HEAD" });
        const t = r.headers.get("content-type") ?? "";
        if (r.status !== 200) fail(`Icon ${url.pathname} returned ${r.status}`);
        else if (!t.startsWith("image/")) fail(`Icon ${url.pathname} has Content-Type "${t}"`);
      } catch (error) {
        fail(`Icon ${url.pathname} failed: ${error.message}`);
      }
    }),
  );

  if (!Array.isArray(manifest.screenshots) || manifest.screenshots.length === 0) {
    warn("No screenshots: Chrome shows the basic install dialog");
  }
}

async function checkServiceWorker() {
  const url = new URL(swPath, base);
  const res = await get(url);
  if (res.status !== 200) return fail(`${swPath} returned ${res.status} (redirects are not allowed)`);
  const type = res.headers.get("content-type") ?? "";
  if (!/javascript|ecmascript/.test(type)) fail(`${swPath} Content-Type is "${type}"`);
  else pass(`${swPath} Content-Type: ${type}`);

  const cc = res.headers.get("cache-control") ?? "";
  const maxAge = Number(cc.match(/max-age=(\d+)/)?.[1] ?? NaN);
  if (/no-cache|no-store/.test(cc) || maxAge === 0) pass(`${swPath} Cache-Control: ${cc}`);
  else fail(`${swPath} Cache-Control is "${cc || "(none)"}"; use no-cache`);

  const body = await res.text();
  if (/<!doctype html/i.test(body.slice(0, 200))) fail(`${swPath} returned HTML (SPA fallback?)`);

  // Two consecutive fetches must return identical bytes, or every check installs an "update".
  const again = await (await get(url)).text();
  if (again !== body) fail(`${swPath} changes between requests (timestamps or nonces in the script?)`);
}

async function checkOfflinePage() {
  const res = await get(new URL(offlinePath, base));
  res.status === 200 ? pass(`Offline page ${offlinePath} is deployed`) : fail(`Offline page returned ${res.status}`);
}

try {
  await checkHttps();
  const manifestUrl = await checkPage();
  if (manifestUrl) await checkManifest(manifestUrl);
  await checkServiceWorker();
  await checkOfflinePage();
} catch (error) {
  fail(`Preflight crashed: ${error.stack ?? error}`);
}

const symbols = { pass: "PASS", warn: "WARN", fail: "FAIL" };
for (const { level, msg } of results) console.log(`${symbols[level]}  ${msg}`);
const failures = results.filter((r) => r.level === "fail").length;
console.log(`\n${failures} blocker(s), ${results.filter((r) => r.level === "warn").length} warning(s)`);
process.exit(failures ? 1 : 0);

What the script can't check is most of this page: behavior on real devices, update flows between two builds, push delivery and accessibility. Combine it with end-to-end tests that exercise offline and update scenarios, as described in Automated Testing.

Common release-day pitfalls

  • A CDN rule caches sw.js for a day. Every user is a day late for every fix, including the kill switch. Verify the CDN's effective headers, not your origin's.
  • The SPA fallback answers /sw.js with index.html after a routing change. Registration fails for new users and update checks fail for existing ones, silently.
  • Assets from the previous build are deleted on deploy while a skipWaiting() worker is live. Old tabs request chunks that no longer exist.
  • The manifest is regenerated with a different id (for example from an environment variable), creating a second app for every installed user.
  • Icons are "optimized" in place. The bytes change but the URLs don't; desktop installs never see the new artwork, while caches serve mixed versions.
  • A new third-party script is added to the page but not to the CSP, or to the precache, which then fails to install because one request returned an opaque or error response.
  • Push is enabled in production with development VAPID keys. Subscriptions made against one key pair can't receive messages signed with another.
  • The release is only tested in a browser tab. The installed app, with its separate window, storage and launch URL, is where users notice regressions.

Further reading

On this site

External references