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-iconoverrides 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;
localhostis the only exception. See Core Building Blocks. - HTTP redirects to HTTPS with a
301or308on every host and path, including the apex domain andwww. Users arriving from old links must never load an insecure page that could inject a different service worker registration. -
Strict-Transport-Securityis sent (for examplemax-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 tohttp: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-Encodingis correct. The Cache API stores the decoded body, so compression helps the first download, not cache size. - Blocker:
sw.jsis served withCache-Control: no-cache(ormax-age=0) and a JavaScriptContent-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.jsis never redirected. A redirect on the worker script fails registration and update checks.curl -sI https://example.com/sw.jsmust return200with noLocationheader. - Hashed static assets are immutable:
Cache-Control: public, max-age=31536000, immutablefor files whose name contains a content hash. See HTTP Caching & Service Workers. - HTML documents revalidate (
Cache-Control: no-cacheor a shortmax-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(orno-storefor secrets) and correctVaryheaders, so shared caches never serve one user's data to another. -
X-Content-Type-Options: nosniffis set, so a mis-typed upload can never be executed as a script. -
Referrer-Policyis set deliberately (for examplestrict-origin-when-cross-origin) so launch URLs with parameters don't leak to third parties. -
Permissions-Policydisables powerful features you don't use (for examplecamera=(), 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 (
200with 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.
# 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;
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:
idis set explicitly and will never change. It defines which app an installed icon belongs to. If you already shipped withoutid, copy the Computed App Id from DevTools into it. See App Identity & Updates. -
start_urlis set, same-origin, insidescope, and works offline. Launching the installed app with no network must not show the browser's error page. -
start_urlcarries a source parameter (for example/?source=pwa) for analytics, and never a user identifier. -
scopeis set and ends in/. Scope matching is string-prefix based:/appalso matches/apple. -
nameandshort_nameare set.short_nameis what users see under the icon; test it on a small phone for truncation. -
displayisstandalone(orminimal-ui/fullscreenwhen justified). Enhanced modes such aswindow-controls-overlaygo indisplay_override, never indisplay. See Display Modes. -
theme_colorandbackground_colorare set, andbackground_colormatches the CSSbodybackground so there is no flash between the Android splash screen and first paint. -
descriptionandscreenshotsare set for Chrome's richer install dialog: at least onewidescreenshot for desktop and onenarrowfor mobile, each with alabel. See Rich Install UI. -
shortcutspoint 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_applicationsis absent orfalse, unless you deliberately want Android users sent to a native app.truestops 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(ordefault-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/EdgeHTMLmsapplication-*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, withsizesdeclared. Chromium's installability check fails with maskable-only icons or undeclared sizes. See Installability Criteria. - 192 × 192 and 512 × 512 PNG
anyicons are present; Android's splash screen and WebAPK minting look best with a 512 px source. - Separate
maskableicons (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
monochromeicon 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.icowith 16/32/48 px and an SVG icon) are present for tabs and bookmarks. - Icons are served with correct
Content-Typeand 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-Allowedis 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 afterload, 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
awaitmiss 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
fetchhandler 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 callsrespondWith(), 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.readywithout a timeout; it never resolves if registration fails.
// 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
activatedeletes 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: *andCache-Control: no-storeresponses beforecache.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 orignoreSearchis 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 aResponse. - Media routes support
Rangerequests 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.
-
controllerchangereloads 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 onvisibilitychange; 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_urland 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.onLineonly 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
onlineeverywhere 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".
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. -
QuotaExceededErroris 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
versionchangeandblockedevents 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()usesuserVisibleOnly: trueand the application server key as aUint8Arrayor base64url string. - Blocker: every
pushevent shows a notification insideevent.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.
-
404and410responses from the push service delete the subscription on your server; they mean it has expired or was revoked (RFC 8030). - Messages set
TTL(andUrgencywhere 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
aes128gcmencryption (RFC 8291) and a single record that leaves 3,993 bytes of plaintext. Send an ID and fetch the details if you need more. -
notificationclickfocuses an existing window when one is open and navigates it, instead of always opening a new one. See Notifications API. - Notification
tagvalues 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
beforeinstallpromptlistener is attached synchronously at startup; a listener attached after anawaitmisses 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: standaloneorappinstalledfired) 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">matchesshort_name, or is omitted. -
<meta name="apple-mobile-web-app-status-bar-style">is set deliberately:default,blackorblack-translucent. Withblack-translucent, content runs under the status bar, so pad withenv(safe-area-inset-top). - The viewport includes
viewport-fit=coverwhen content extends under the status bar or home indicator, and every edge usesenv(safe-area-inset-*). -
apple-touch-startup-imagesplash screens exist for the device sizes you care about, or you accept a blank launch screen. Safari doesn't readbackground_colorand generates no splash screen from the manifest. See Splash Screens & Theming. - Two
<meta name="theme-color">tags withmediafor light and dark are present; Safari 26 uses theme color only for installed web apps. - The legacy
apple-mobile-web-app-capabletag 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-vitalslibrary 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=noormaximum-scale=1in 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-liveregion, 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-motionis respected in view transitions and splash animations. See View Transitions. -
theme_colorcontrasts with the system's title-bar and status-bar text in both light and dark themes. - Screenshots and icons have labels:
labelon 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
titleandbody.
Security: CSP and service worker security¶
- Blocker: a Content Security Policy is deployed, with at least
default-src 'self',script-srcwithout'unsafe-inline'(use nonces or hashes),object-src 'none'andbase-uri 'none'. See Content Security Policy. -
worker-src 'self'is set explicitly. Without it, the policy for workers (including service workers) falls back tochild-src, thenscript-src, thendefault-src. -
manifest-src 'self'(or your manifest origin) is set. -
connect-srcdoesn'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 governsimportScripts()andfetch()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-Allowedis 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, includingevent.originfor 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_urlor shortcut URLs, no fingerprinting through installed-app detection, storage partitioning understood for embedded contexts. See Privacy & Storage Partitioning.
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_urlsource parameter and shortcut parameters. See Analytics for PWAs. - Display mode is a dimension on every hit:
standalone,minimal-ui,fullscreen,window-controls-overlayorbrowser, frommatchMedia("(display-mode: …)"). - Install funnel events are tracked:
beforeinstallpromptreceived, 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-storein Edge, and Trusted Web Activity launches with anandroid-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;
notificationclickand 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
500for 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
errorandunhandledrejectionin 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(),QuotaExceededErrorcounts. - Push health is visible: send success rate,
404/410rate, 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-Dataemergency configuration is documented forsw.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".
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.jsonlists 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
.msixbundleand.classic.appxbundleare 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,descriptionandcategories, updated per store taxonomy. - An age rating is obtained where the store requires one; set
iarc_rating_idonly 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.
#!/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.jsfor 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.jswithindex.htmlafter 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
- Pitfalls & Anti-Patterns: the service-worker pre-release checklist this page extends
- Updating Service Workers: update patterns, rollback and kill switches
- Installability Criteria
- Manifest Cheat Sheet
- Offline UX & Fallbacks
- Storage Quotas & Persistence
- Web Push on iOS & Safari
- Migrating an Existing Site
External references
- MDN: Progressive web apps
- W3C: Service Workers
- W3C: Web Application Manifest
- web.dev: Web Vitals
- RFC 8030: Generic Event Delivery Using HTTP Push
- MDN: Content-Security-Policy worker-src
- W3C: Web Content Accessibility Guidelines (WCAG) 2.2
- Android Developers: Target API level requirements for Google Play
- Microsoft Learn: Publish a PWA to the Microsoft Store
- Apple: App Review Guidelines