PWA and Service Workers skill for Codex

Installable, offline-ready, and safe to update. Codex builds PWAs that install correctly and update without breaking open sessions. It picks a caching strategy per request type instead of caching everything, keeps personal data out of shared caches, wires an update-available prompt rather than blind skipWaiting, and validates the manifest with a bundled Node script that also checks icon file dimensions. A tested kill switch is part of the delivery.

  • Frontend
  • $pwa-service-workers
  • 206 lines
  • 3 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pwa-service-workers

Installs to ~/.agents/skills/pwa-service-workers/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $pwa-service-workers, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Turns web apps into installable, offline-capable PWAs - web app manifest with maskable icons and stable id, service worker registration and lifecycle, Workbox caching strategies (precache, stale-while-revalidate, network-first), offline fallback pages, safe update prompts, navigation preload, background sync and web push, via vite-plugin-pwa, Serwist or workbox-build. Use when a user wants an app installable or working offline, sees stale content after deploys, needs an update available prompt, asks about caching strategies, push notifications, or must remove a broken service worker.

Use it when

  • An app should be installable or work offline
  • Deploys leave users on stale versions or cause chunk-load errors
  • You need push notifications or offline write queues
  • A broken service worker must be removed from users' browsers

Use something else when

  • Choosing between localStorage, IndexedDB and OPFS for app data: use browser-storage
  • HTTP Cache-Control headers for assets and APIs: use http-caching

How to check the result

Run the manifest checker on the built output, test pages with the network set to Offline, and deploy a second local build to confirm the update prompt and a clean reload.

The mistake it prevents

Calling skipWaiting unconditionally, which lets a new worker serve new chunks to pages still running old HTML and breaks navigation mid-session.

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

--- name: pwa-service-workers description: Turns web apps into installable, offline-capable PWAs - web app manifest with maskable icons and stable id, service worker registration and… license: MIT ---

Done well means: the app installs with correct icons and name, a deploy reaches users within one reload cycle without serving mixed old and new assets, offline users see cached content or a clear offline page (never the browser dinosaur), authenticated data is not leaked across users from caches, and there is a tested way to turn the service worker off.

Before changing anything

  1. Read AGENTS.md, the build tool config and any existing manifest.*, sw.*, or registerSW code. Check navigator.serviceWorker usage with rg -n "serviceWorker|workbox|serwist|VitePWA" --glob '!node_modules'.
  2. Confirm HTTPS in production (service workers need a secure context; localhost is exempt).
  3. Ask what must work offline: app shell only, recently viewed content, or writes (queue and sync). That decides the caching plan. Do not cache everything by default.
  4. Plugin installs need network; ask for approval in a sandbox.

Tooling

StackDefault
Vite (React, Vue, Svelte, Solid)vite-plugin-pwa (Workbox under the hood)
Next.jsSerwist (@serwist/next), the maintained successor to next-pwa
Nuxt@vite-pwa/nuxt
SvelteKitBuilt-in src/service-worker.js with $service-worker asset lists
Other / custom buildworkbox-build (injectManifest) in a post-build step

Prefer injectManifest (you write the SW, the tool injects the precache list) once you need custom routes, push or sync. generateSW is fine for app-shell-only caching.

Manifest

{
  "id": "/",
  "name": "Field Notes",
  "short_name": "Notes",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "theme_color": "#0f172a",
  "background_color": "#ffffff",
  "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" }
  ],
  "screenshots": [
    { "src": "/shots/wide.png", "sizes": "1280x800", "type": "image/png", "form_factor": "wide" },
    { "src": "/shots/narrow.png", "sizes": "750x1334", "type": "image/png" }
  ]
}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#0f172a">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png">

Validate it with the bundled checker (run after build so --public points at served files):

node scripts/check_manifest.mjs dist/manifest.webmanifest --public dist

It fails on missing 192/512 icons, non-installable display, start_url outside scope, and icon files whose PNG dimensions do not match sizes; it warns on missing id, maskable icon, screenshots and colors.

  • Set id once and never change it; it is the app’s identity for installed users.
  • Maskable icons keep important art inside the central 80% safe zone; use a separate file, not "purpose": "any maskable".

Caching strategy per request type

RequestStrategyNotes
Hashed build assets (JS, CSS, fonts)PrecacheVersioned by the build; served instantly
HTML navigationsNetwork-first with timeout (3 s) → cached page → offline fallbackKeeps content fresh; or precache the SPA shell
Images, avatarsStale-while-revalidate or cache-first with expiration (max entries, max age)Always cap entries
Public, slowly changing API (catalog)Stale-while-revalidateShow cached, refresh in background
Authenticated or personal API dataNetwork-only, or network-first into a per-user cache cleared on logoutNever shared caches
POST/PUT writesNetwork-only + Background Sync queue if offline writes are requiredIdempotency keys on the server
Third-party opaque responsesAvoid caching or cap tightlyOpaque responses count large against quota

Service worker (injectManifest, Workbox)

// src/sw.ts
/// <reference lib="webworker" />
import { precacheAndRoute, cleanupOutdatedCaches } from "workbox-precaching";
import { registerRoute, NavigationRoute, setCatchHandler } from "workbox-routing";
import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import * as navigationPreload from "workbox-navigation-preload";

declare const self: ServiceWorkerGlobalScope;

precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
navigationPreload.enable();

registerRoute(new NavigationRoute(new NetworkFirst({
  cacheName: "pages", networkTimeoutSeconds: 3,
})));

registerRoute(({ request }) => request.destination === "image",
  new StaleWhileRevalidate({
    cacheName: "images",
    plugins: [new ExpirationPlugin({ maxEntries: 120, maxAgeSeconds: 30 * 24 * 3600 })],
  }));

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();
});

Precache /offline.html (add it to the glob patterns) so the catch handler can serve it.

Updates without breaking sessions

  1. New SW installs and waits while old tabs are open.
  2. The page detects the waiting worker and shows “Update available, reload”.
  3. On click, post SKIP_WAITING; on controllerchange, reload once.
import { registerSW } from "virtual:pwa-register";   // vite-plugin-pwa, registerType: "prompt"
const updateSW = registerSW({
  onNeedRefresh() { showToast("New version available", () => updateSW(true)); },
  onOfflineReady() { showToast("Ready to work offline"); },
});
  • Calling skipWaiting() unconditionally on install can mix old HTML with new chunks and cause chunk-load errors mid-session. Use it only for SWs that serve nothing stateful.
  • Serve sw.js with Cache-Control: no-cache so the browser checks for updates on every navigation; never cache the SW file at the CDN for long.
  • Handle chunk-load failures in the router (reload once) as a safety net.

Push and background sync

  • Web push needs a VAPID key pair, a server that stores subscriptions, and permission requested from a user gesture after explaining the value; never on page load. On iOS/iPadOS push works only for apps added to the Home Screen (16.4+).
  • Sending pushes to real users is outward-facing: confirm with the user before triggering any test send outside a development subscription.
  • Background Sync (workbox-background-sync BackgroundSyncPlugin) is Chromium-only; keep a foreground retry path for other browsers.

Kill switch

If a broken SW is in the wild, deploy a replacement at the same URL:

// sw.js — emergency unregister. Deploy only after the user confirms.
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({ type: "window" })) c.navigate(c.url);
});

This deletes every cache for all users of the origin; confirm with the user before deploying. Clear-Site-Data: "cache", "storage" on a response is an alternative with wider blast radius.

Gotchas

  • SW scope is its URL directory: /static/sw.js cannot control /. Serve it at the root or set Service-Worker-Allowed.
  • Dev servers plus a registered SW serve stale code; enable “Update on reload” in DevTools Application panel, or disable the SW in development (devOptions.enabled: false).
  • Caching responses with Set-Cookie or Vary: Cookie content into a shared cache leaks data between accounts on shared devices. Clear user caches on logout.
  • Storage can be evicted; call navigator.storage.persist() only for apps where offline data matters, and check navigator.storage.estimate() against what you cache.
  • Redirected navigation responses cannot be served from a SW for navigations in some browsers; cache the final URL.

Verification

  • node scripts/check_manifest.mjs <built manifest> --public <dist> exits 0.
  • DevTools Application panel: manifest has no errors, SW is activated and controls the page, caches contain only expected entries.
  • Network set to Offline: reload key pages; cached ones load, others show the offline page.
  • Deploy a second build locally: update prompt appears, reload switches cleanly, no chunk errors.
  • Lighthouse PWA-related checks (installability) pass; install on Android Chrome and desktop Chrome; Add to Home Screen on iOS Safari shows correct name and icon.
  • Log out and confirm per-user caches are deleted.

Output

Report: tool and mode (generateSW/injectManifest), manifest changes, the strategy per request type, update UX, offline fallback, push/sync status, and the kill-switch procedure.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "PWA and Service Workers"
  short_description: "Installable, offline-ready, and safe to update"
  default_prompt: "$pwa-service-workers Make this Vite React app installable and usable offline"
scripts/check_manifest.mjs95 lines, 4.6 KB
#!/usr/bin/env node
// Checks a web app manifest for installability and common mistakes.
// Usage: node check_manifest.mjs <path/to/manifest.webmanifest> [--public <dir>]
//   --public  directory that the site root "/" maps to (e.g. public, dist); when given,
//             icon and screenshot files are checked on disk, including PNG pixel sizes.
// Exit 0 when there are no errors (warnings allowed), 1 otherwise. Node 20+, no dependencies.
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';

const args = process.argv.slice(2);
const file = args.find((a) => !a.startsWith('--'));
const pubIdx = args.indexOf('--public');
const publicDir = pubIdx >= 0 ? resolve(args[pubIdx + 1] ?? '.') : null;
if (!file) {
  console.error('usage: node check_manifest.mjs <manifest.webmanifest> [--public <dir>]');
  process.exit(1);
}

const errors = [];
const warns = [];
let m;
try {
  m = JSON.parse(readFileSync(file, 'utf8'));
} catch (err) {
  console.error(`error: cannot read or parse ${file}: ${err.message}`);
  process.exit(1);
}

const base = new URL('https://app.invalid/');
const abs = (u) => new URL(u ?? '.', base);

if (!m.name && !m.short_name) errors.push('name or short_name is required');
if (!m.short_name) warns.push('short_name missing: home screen labels will truncate name');
else if (m.short_name.length > 12) warns.push(`short_name "${m.short_name}" is ${m.short_name.length} chars; keep it to about 12`);
if (!m.start_url) errors.push('start_url is required');
if (!m.id) warns.push('id missing: browsers derive identity from start_url, so changing start_url later creates a "new" app');
const display = m.display ?? 'browser';
if (!['standalone', 'fullscreen', 'minimal-ui'].includes(display)) {
  errors.push(`display is "${display}"; installable PWAs need standalone, fullscreen or minimal-ui`);
}
if (m.scope && m.start_url && !abs(m.start_url).pathname.startsWith(abs(m.scope).pathname)) {
  errors.push(`start_url ${m.start_url} is outside scope ${m.scope}`);
}
if (!m.theme_color) warns.push('theme_color missing (title bar / status bar color)');
if (!m.background_color) warns.push('background_color missing (splash screen color)');

const icons = Array.isArray(m.icons) ? m.icons : [];
if (!icons.length) errors.push('icons array is empty');
const sizesOf = (i) => String(i.sizes ?? '').split(/\s+/).filter(Boolean);
const has = (px, purpose = 'any') => icons.some((i) =>
  (sizesOf(i).includes(`${px}x${px}`) || sizesOf(i).includes('any')) &&
  String(i.purpose ?? 'any').split(/\s+/).includes(purpose));
if (!has(192)) errors.push('no 192x192 icon with purpose "any"');
if (!has(512)) errors.push('no 512x512 icon with purpose "any"');
if (!icons.some((i) => String(i.purpose ?? '').split(/\s+/).includes('maskable'))) {
  warns.push('no maskable icon: Android will shrink the icon onto a white circle');
}
for (const i of icons) {
  const p = String(i.purpose ?? '');
  if (p.includes('any') && p.includes('maskable')) {
    warns.push(`icon ${i.src} uses purpose "any maskable": provide separate files; maskable art looks cropped where "any" is used`);
  }
  if (!i.type) warns.push(`icon ${i.src} has no type (e.g. image/png)`);
}
const shots = Array.isArray(m.screenshots) ? m.screenshots : [];
if (!shots.length) warns.push('no screenshots: Chrome shows the richer install dialog only with them');
else {
  if (!shots.some((s) => s.form_factor === 'wide')) warns.push('no screenshot with form_factor "wide" (desktop install UI)');
  if (!shots.some((s) => s.form_factor !== 'wide')) warns.push('no narrow (mobile) screenshot');
}

function pngSize(path) {
  const buf = readFileSync(path);
  if (buf.length < 24 || buf.toString('ascii', 1, 4) !== 'PNG') return null;
  return [buf.readUInt32BE(16), buf.readUInt32BE(20)];
}

if (publicDir) {
  const manifestDir = dirname(resolve(file));
  for (const item of [...icons, ...shots]) {
    if (!item.src || /^https?:/.test(item.src)) continue;
    const path = item.src.startsWith('/') ? join(publicDir, item.src) : join(manifestDir, item.src);
    if (!existsSync(path)) { errors.push(`file not found for ${item.src} (looked at ${path})`); continue; }
    const dims = pngSize(path);
    const declared = sizesOf(item)[0];
    if (dims && declared && declared !== 'any' && declared !== `${dims[0]}x${dims[1]}`) {
      errors.push(`${item.src} declares ${declared} but the PNG is ${dims[0]}x${dims[1]}`);
    }
  }
}

for (const w of warns) console.log(`warn:  ${w}`);
for (const e of errors) console.log(`error: ${e}`);
console.log(errors.length ? `\n${errors.length} error(s), ${warns.length} warning(s)` : `\nOK: no errors, ${warns.length} warning(s)`);
process.exit(errors.length ? 1 : 0);

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem