PWAs and Service Workers skill for Claude Code

Installable, offline-ready, never stuck on old builds. Claude builds PWAs that install cleanly and update safely. It writes a complete manifest with separate maskable icons, picks a Workbox strategy per resource type, adds an offline fallback page, and ships a reload prompt instead of surprise skipWaiting swaps. A bundled stdlib script audits the manifest, icon files and service worker for the mistakes that strand users on stale builds.

  • namepwa-service-workers
  • size202 lines · ≈2,250 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Turns web apps into installable, offline-capable PWAs — a complete web app manifest with maskable icons, a service worker built with Workbox 7 (via vite-plugin-pwa, Serwist, or workbox-build), per-resource caching strategies, an offline fallback page, safe update flow with a reload prompt, and Lighthouse/DevTools verification. Includes a stdlib audit script for manifests and service worker files. Use when adding a manifest or sw.js, the install prompt never appears, users are stuck on an old version after deploy, offline mode shows the dino page, or caches grow without bound.

Try asking

  • Make this Vite app installable and usable offline
  • Users keep seeing the old version after we deploy — fix our service worker
  • Audit our manifest and sw.js and tell me why Chrome will not offer install

Use it when

  • Adding a web app manifest or service worker to an existing app
  • The install prompt does not appear or icons look cropped
  • Users stay on an outdated build after deploys
  • Offline navigation shows the browser error page

Skip it when

  • You need a local-first data layer with sync and conflict handling — use offline-sync
  • The problem is CDN or browser HTTP cache headers — use http-caching

How to check the result

The audit script exits 0, DevTools shows an activated worker and expected caches, an offline reload renders the shell or offline page, and a rebuild triggers the update prompt.

The common mistake it prevents

Serving HTML cache-first, or renaming sw.js per build, which leaves users running an old version indefinitely.

~/.claude/skills/pwa-service-workers/SKILL.md

--- name: pwa-service-workers description: Turns web apps into installable, offline-capable PWAs — a complete web app manifest with maskable icons, a service worker built with… license: MIT ---

A good PWA installs cleanly, opens offline to something useful, never traps users on an old build, and caches each resource type with a strategy chosen on purpose. A service worker that caches HTML cache-first with no update path is worse than none.

Step 0 — inspect what exists

ls public/manifest.* public/*.webmanifest public/sw.js src/sw.* 2>/dev/null
grep -rn "serviceWorker.register\|registerSW\|VitePWA\|@serwist\|workbox" src app vite.config.* next.config.* package.json 2>/dev/null
python3 scripts/audit_pwa.py public/manifest.webmanifest --sw public/sw.js

scripts/audit_pwa.py checks manifest fields, icon sizes and purposes, icon files on disk, and common service worker mistakes (unconditional skipWaiting, HTML served cache-first, no navigation fallback, no cache cleanup). It exits 1 on errors.

Tooling choice

StackUse
Vite (React, Vue, Svelte, Solid)vite-plugin-pwa (Workbox under the hood)
Next.jsSerwist (@serwist/next) — successor of next-pwa
SvelteKitBuilt-in src/service-worker.js with $service-worker manifest
Angularng add @angular/pwa (Angular service worker, ngsw-config.json)
Anything else / custom buildworkbox-build injectManifest with a hand-written SW

Prefer injectManifest (you write the SW, the tool injects the precache list) once you need custom routes, push, or background sync. generateSW is fine for plain static precaching.

Manifest

{
  "id": "/",
  "name": "Acme Field Notes",
  "short_name": "Field Notes",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#f6f4ef",
  "theme_color": "#1d3b2a",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    { "src": "/screens/wide.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
    { "src": "/screens/narrow.png", "sizes": "720x1280", "type": "image/png" }
  ]
}
  • Link it: <link rel="manifest" href="/manifest.webmanifest">, plus <meta name="theme-color"> and <link rel="apple-touch-icon" href="/icons/180.png"> (iOS ignores manifest icons for the home screen).
  • Maskable icon: keep the logo inside the central 80% safe zone; test at maskable.app. Do not mark one icon "any maskable" — it crops badly in one context or the other.
  • id keeps installs stable if start_url changes later.
  • screenshots enable the richer install dialog on Chromium.
  • Serve over HTTPS (localhost is exempt).

Caching strategy per resource

ResourceStrategyExpiration
Hashed JS/CSS/fonts from your buildPrecache (revisioned by hash)Replaced on next SW version
HTML navigationsNetworkFirst with 3 s timeout, fallback to cache, then /offline.htmlSmall (e.g. 25 entries)
API GETs where stale is acceptable (catalog, profile)StaleWhileRevalidatemaxEntries 100, maxAge 1 day
API GETs that must be fresh (balances, carts)NetworkOnly (no cache)—
User images / CDN imagesCacheFirstmaxEntries 200, maxAge 30 days, purgeOnQuotaError
Third-party opaque responsesAvoid caching or CacheFirst with cacheableResponse: { statuses: [0, 200] } and tight maxEntries (opaque responses count ~7 MB each against quota in Chromium)
POST/PUT/DELETENever cached; queue with BackgroundSync if offline writes are required

Workbox example (injectManifest SW):

import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, NavigationRoute, setCatchHandler } from "workbox-routing";
import { NetworkFirst, StaleWhileRevalidate, CacheFirst } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";

declare const self: ServiceWorkerGlobalScope;

precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

registerRoute(new NavigationRoute(new NetworkFirst({
  cacheName: "pages", networkTimeoutSeconds: 3,
  plugins: [new ExpirationPlugin({ maxEntries: 25 })],
})));

registerRoute(({ url }) => url.pathname.startsWith("/api/catalog"),
  new StaleWhileRevalidate({ cacheName: "api-catalog", plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 86400 })] }));

registerRoute(({ request }) => request.destination === "image",
  new CacheFirst({ cacheName: "images", plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 86400, purgeOnQuotaError: true })] }));

setCatchHandler(async ({ request }) =>
  request.destination === "document" ? (await caches.match("/offline.html")) ?? Response.error() : Response.error());

self.addEventListener("message", (e) => { if (e.data?.type === "SKIP_WAITING") self.skipWaiting(); });

Add /offline.html to the precache list (globPatterns or additionalManifestEntries).

Update flow

The lifecycle: new SW installs → waits while any tab uses the old one → activates.

Default for apps with state (forms, editors): prompt.

// vite-plugin-pwa: registerType: "prompt"
import { registerSW } from "virtual:pwa-register";
const updateSW = registerSW({
  onNeedRefresh() { showToast("A new version is available", { action: "Reload", onAction: () => updateSW(true) }); },
  onOfflineReady() { showToast("Ready to work offline"); },
});
  • updateSW(true) posts SKIP_WAITING and reloads once the new SW controls the page.
  • registerType: "autoUpdate" (skipWaiting + clientsClaim) only for content sites where a surprise reload loses nothing.
  • Serve sw.js with Cache-Control: no-cache (or max-age=0). Browsers cap SW script HTTP caching at 24 h, but a CDN can still serve a stale file — purge it on deploy.
  • Never rename the SW file per build (sw.abc123.js); the old registered URL keeps running forever.
  • Long-lived tabs: call registration.update() on an interval (e.g. hourly) or on visibilitychange.

Install prompt

  • Chromium fires beforeinstallprompt once installability criteria pass; stash the event and show your own install button at a meaningful moment (after a completed task), not on first load.
  • iOS Safari has no prompt event: show a one-time hint pointing at Share → Add to Home Screen when navigator.standalone is false.
  • Detect installed mode with matchMedia("(display-mode: standalone)") for analytics.

Kill switch

Keep a no-op worker ready in case a bad SW ships:

// sw.js (emergency replacement)
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
  for (const key of await caches.keys()) await caches.delete(key);
  await self.registration.unregister();
  for (const c of await self.clients.matchAll()) c.navigate(c.url);
});

Deploying this to the same URL is a production action — confirm with the user before shipping it. Alternatively send Clear-Site-Data: "storage" from a page.

Gotchas

  • SW scope is the directory of the script: /assets/sw.js cannot control /. Serve it from the root (or set Service-Worker-Allowed).
  • Dev servers: disable the SW in development (devOptions.enabled: false) or you debug stale code. Use DevTools → Application → “Update on reload”.
  • Precaching everything (videos, all images, every locale) makes first install download megabytes. Precache the app shell only; runtime-cache the rest.
  • Auth: never cache responses that vary by user in a shared cache name, and clear caches on logout.
  • Range requests (audio/video) need workbox-range-requests or should bypass the SW.
  • Safari evicts script-writable storage after ~7 days without use for non-installed sites; do not treat the cache as durable storage. Use IndexedDB plus navigator.storage.persist() for data that matters.

Verification

  1. python3 scripts/audit_pwa.py <manifest> --sw <built sw.js> exits 0.
  2. Build and serve the production output (not dev). In DevTools → Application: manifest has no errors, SW is activated, caches contain the expected names.
  3. Network → Offline, reload: the app shell or /offline.html renders, not the browser error page. Navigate to an uncached route: offline fallback appears.
  4. Deploy a change (or rebuild locally): the update prompt appears; accepting it reloads onto the new version; no tab stays on the old build.
  5. Lighthouse in Chrome: the PWA installability checks pass (the dedicated PWA category was removed from Lighthouse in v12; use the Application panel’s installability section).

Report format

**Tooling:** vite-plugin-pwa injectManifest, registerType prompt
**Manifest:** id, 192/512 + maskable icons, screenshots wide/narrow
**Caching:** shell precache (412 kB), pages NetworkFirst 3 s, /api/catalog SWR 1 d, images CacheFirst 200/30 d
**Offline:** /offline.html fallback for documents
**Verified:** audit script clean, offline reload, update prompt on rebuild

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/audit_pwa.py 148 lines · 5.9 KB
#!/usr/bin/env python3
"""Audit a web app manifest and (optionally) a service worker file.

Usage:
  python3 audit_pwa.py <manifest.json|.webmanifest> [--sw path/to/sw.js] [--root public/]

--root is the directory that "/"-prefixed icon paths resolve against
(default: the manifest's directory). Exits 1 if any ERROR is found.
"""
import argparse
import json
import re
import struct
import sys
from pathlib import Path

errors, warnings, ok = [], [], []


def err(m): errors.append(m)
def warn(m): warnings.append(m)
def good(m): ok.append(m)


def png_size(path: Path):
    try:
        with path.open("rb") as f:
            head = f.read(24)
        if head[:8] != b"\x89PNG\r\n\x1a\n":
            return None
        return struct.unpack(">II", head[16:24])
    except OSError:
        return None


def check_manifest(path: Path, root: Path):
    try:
        m = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as e:
        err(f"manifest unreadable or invalid JSON: {e}")
        return
    for key in ("name", "short_name", "start_url", "display", "icons"):
        if key not in m:
            err(f"manifest missing '{key}'")
    if "short_name" in m and len(m["short_name"]) > 12:
        warn(f"short_name '{m['short_name']}' is {len(m['short_name'])} chars; launchers truncate around 12")
    if m.get("display") not in (None, "standalone", "fullscreen", "minimal-ui", "browser"):
        err(f"display '{m.get('display')}' is not a valid value")
    if m.get("display") == "browser":
        warn("display 'browser' is not installable as an app window; use 'standalone'")
    for key in ("id", "scope", "theme_color", "background_color"):
        if key not in m:
            warn(f"manifest has no '{key}'")
    if "screenshots" not in m:
        warn("no screenshots: Chromium shows the basic install dialog instead of the richer one")

    icons = m.get("icons") or []
    sizes_any, has_maskable = set(), False
    for icon in icons:
        src = icon.get("src", "")
        purpose = icon.get("purpose", "any").split()
        if "any" in purpose and "maskable" in purpose:
            warn(f"{src}: purpose 'any maskable' — ship separate any and maskable icons")
        if "maskable" in purpose:
            has_maskable = True
        declared = icon.get("sizes", "")
        if "any" in purpose:
            sizes_any.update(declared.split())
        file = (root / src.lstrip("/")) if src.startswith("/") else (path.parent / src)
        if src.startswith(("http://", "https://")):
            continue
        if not file.exists():
            err(f"icon file not found: {src} (looked at {file})")
            continue
        if file.suffix.lower() == ".png" and declared and declared != "any":
            real = png_size(file)
            first = declared.split()[0]
            if real and first != f"{real[0]}x{real[1]}":
                err(f"{src}: declared {first} but PNG is {real[0]}x{real[1]}")
    for need in ("192x192", "512x512"):
        if need in sizes_any:
            good(f"icon {need} present")
        else:
            err(f"no 'any'-purpose icon declared at {need}")
    if not has_maskable:
        warn("no maskable icon: Android will letterbox the icon on a white circle")


SW_RULES = [
    (r"skipWaiting\(\)", r"SKIP_WAITING|message", "WARN",
     "skipWaiting() is called unconditionally — users get mid-session swaps; gate it behind a SKIP_WAITING message unless the site is stateless"),
    (r"(NavigationRoute|request\.mode\s*===?\s*['\"]navigate|destination\s*===?\s*['\"]document)", None, "WARN_MISSING",
     "no navigation handling found — offline navigations will show the browser error page"),
    (r"(offline\.html|setCatchHandler|offlineFallback)", None, "WARN_MISSING",
     "no offline fallback found (offline.html / setCatchHandler)"),
    (r"(cleanupOutdatedCaches|caches\.delete)", None, "WARN_MISSING",
     "no cache cleanup found — old caches accumulate across versions"),
    (r"(ExpirationPlugin|maxEntries)", None, "WARN_MISSING",
     "no expiration plugin/maxEntries — runtime caches can grow until quota errors"),
]


def check_sw(path: Path):
    try:
        src = path.read_text(encoding="utf-8", errors="replace")
    except OSError as e:
        err(f"service worker unreadable: {e}")
        return
    for pattern, unless, kind, msg in SW_RULES:
        found = re.search(pattern, src)
        if kind == "WARN" and found and not (unless and re.search(unless, src)):
            warn(f"sw: {msg}")
        if kind == "WARN_MISSING" and not found:
            warn(f"sw: {msg}")
    cache_first_html = re.search(r"(CacheFirst|cacheFirst)[^;]{0,200}(navigate|document|\.html)", src, re.S) or \
        re.search(r"(navigate|document|\.html)[^;]{0,200}(CacheFirst|cacheFirst)", src, re.S)
    if cache_first_html:
        err("sw: HTML/navigations appear to use CacheFirst — users will be stuck on old builds; use NetworkFirst")
    if re.search(r"(method\s*===?\s*['\"](POST|PUT|DELETE|PATCH)['\"])[^;]{0,200}cache\.put", src, re.S):
        err("sw: non-GET responses appear to be written to the cache")
    if re.search(r"\bsw\.[0-9a-f]{6,}\.js|service-worker\.[0-9a-f]{6,}\.js", src):
        warn("sw: hashed service worker filename referenced — keep the SW URL stable across deploys")
    if not errors:
        good(f"sw: {path.name} has no blocking issues")


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("manifest", type=Path)
    ap.add_argument("--sw", type=Path)
    ap.add_argument("--root", type=Path)
    a = ap.parse_args()
    root = a.root or a.manifest.parent
    check_manifest(a.manifest, root)
    if a.sw:
        check_sw(a.sw)
    for m in ok:
        print(f"OK     {m}")
    for m in warnings:
        print(f"WARN   {m}")
    for m in errors:
        print(f"ERROR  {m}")
    print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)")
    sys.exit(1 if errors else 0)


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc