# Pwa

> Build and audit Progressive Web Apps - web manifest, service worker with correct caching strategies, offline support, install prompt, push notifications, and Lighthouse PWA criteria. Use when making a web app installable, offline-capable, or when debugging service worker and caching issues.

- Skill: `zloysega/pwa` (Agent Skill)
- Install (CLI): `npx skillmds@latest add zloysega/pwa`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zloysega/pwa/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: ZloySega (https://skillmd.com/u/zloysega)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/zloysega/pwa

---


# Progressive Web Apps

You turn web apps into installable, offline-capable PWAs. Work through
the checklist in order — each layer depends on the previous one.

## 1. Web App Manifest

Create `manifest.webmanifest` (preferred over `.json`) and link it:

```html
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#6C3DF4">
```

Minimum viable manifest:

```json
{
  "name": "App Name",
  "short_name": "App",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#111117",
  "theme_color": "#6C3DF4",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ]
}
```

Rules:
- A **maskable** icon is required for decent Android install UX. Keep the
  safe zone: important pixels inside the inner 80% circle.
- `start_url` must be cached by the service worker, or offline install
  checks fail.
- iOS ignores most of the manifest: also add
  `<link rel="apple-touch-icon" href="/icons/icon-180.png">` and check
  `display-mode: standalone` via media query for standalone detection.

## 2. Service worker: registration

Register late, never block first paint:

```js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js');
  });
}
```

- Serve `sw.js` from the **origin root** (scope covers the whole app).
- Never cache `sw.js` itself with long max-age; the browser revalidates
  it, but an aggressive CDN header (`Cache-Control: max-age=31536000`)
  can freeze users on an old version for a day. Use `max-age=0` or
  `no-cache` for the worker file.

## 3. Caching strategies — pick per resource type

Do NOT use one strategy for everything. Standard split:

| Resource | Strategy |
|---|---|
| App shell (HTML) | Network-first, fallback to cache |
| Hashed static assets (`app.3f2a1.js`) | Cache-first (immutable) |
| API GET data | Stale-while-revalidate |
| API mutations (POST/PUT) | Network-only (+ background sync queue) |
| Images | Cache-first with size-limited cache |

Skeleton worker without libraries:

```js
const VERSION = 'v3';                 // bump on every deploy
const SHELL = ['/', '/index.html', '/offline.html'];

self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)));
  self.skipWaiting();
});

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

self.addEventListener('fetch', (e) => {
  const url = new URL(e.request.url);
  if (e.request.method !== 'GET') return;
  if (e.request.mode === 'navigate') {
    // network-first для навигаций
    e.respondWith(
      fetch(e.request)
        .then((r) => { const copy = r.clone();
          caches.open(VERSION).then((c) => c.put(e.request, copy));
          return r; })
        .catch(() => caches.match(e.request)
          .then((r) => r || caches.match('/offline.html'))));
    return;
  }
  // cache-first для остального
  e.respondWith(
    caches.match(e.request).then((hit) => hit ||
      fetch(e.request).then((r) => { const copy = r.clone();
        caches.open(VERSION).then((c) => c.put(e.request, copy));
        return r; })));
});
```

For anything bigger, use **Workbox** (`workbox-build` /
`vite-plugin-pwa`) instead of hand-rolling: precache manifest with
revision hashes, `registerRoute` per strategy. For Vue/Vite projects
`vite-plugin-pwa` is the default choice — it generates the manifest,
the worker and handles updates.

## 4. Updates without stuck clients

The classic PWA bug: users stuck on an old version. Always implement:

```js
navigator.serviceWorker.register('/sw.js').then((reg) => {
  reg.addEventListener('updatefound', () => {
    const w = reg.installing;
    w.addEventListener('statechange', () => {
      if (w.state === 'installed' && navigator.serviceWorker.controller) {
        // новая версия готова — покажи баннер «Обновить»
        showRefreshBanner(() => w.postMessage({type: 'SKIP_WAITING'}));
      }
    });
  });
});
navigator.serviceWorker.addEventListener('controllerchange',
  () => location.reload());
```

In the worker: `message` handler calling `self.skipWaiting()`.

## 5. Install prompt

```js
let deferred;
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferred = e;              // покажи свою кнопку «Установить»
});
async function installApp() {
  if (!deferred) return;
  deferred.prompt();
  await deferred.userChoice; // {outcome: 'accepted'|'dismissed'}
  deferred = null;
}
```

- Chrome/Edge/Android only. On iOS instruct: Share → "Add to Home
  Screen"; detect iOS Safari and show a hint instead of a button.
- Don't nag: show the button in settings or after a meaningful action.

## 6. Push notifications (optional layer)

- `Notification.requestPermission()` **only from a user gesture**.
- Subscribe: `registration.pushManager.subscribe({userVisibleOnly:
  true, applicationServerKey: <VAPID public key>})`.
- Server sends Web Push (RFC 8291) with the VAPID key pair.
- Worker: `push` event → `self.registration.showNotification()`;
  `notificationclick` → focus or open the target URL.
- iOS ≥ 16.4 supports Web Push only for **installed** PWAs.

## 7. Offline data beyond caches

- Cache Storage is for requests; app state lives in **IndexedDB**.
- Queue mutations offline (IndexedDB outbox) and flush on `online` or
  via Background Sync (`sync` event, Chromium only — keep the outbox
  flush on startup as the portable path).

## 8. Audit checklist (run before shipping)

1. Lighthouse → PWA + Performance pass; installability has no errors.
2. DevTools → Application → Service Workers: update on reload works,
   no zombie workers.
3. Offline test: DevTools → Network → Offline → reload → app shell and
   last data render, navigations hit `offline.html` fallback.
4. Install on Android (WebAPK) and iOS (A2HS): icon, splash, standalone
   without browser chrome.
5. Deploy a change → clients get the refresh banner, not a stuck build.
6. HTTPS everywhere including redirects (service workers require it).

