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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- pwa-service-workers Installs to ~/.agents/skills/pwa-service-workers/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install pwa-service-workers The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project pwa-service-workers Installs to .agents/skills/pwa-service-workers/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/pwa-service-workers.zipunzip -o pwa-service-workers.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) pwa-service-workers PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md, the build tool config and any existingmanifest.*,sw.*, orregisterSWcode. Checknavigator.serviceWorkerusage withrg -n "serviceWorker|workbox|serwist|VitePWA" --glob '!node_modules'. - Confirm HTTPS in production (service workers need a secure context;
localhostis exempt). - 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.
- Plugin installs need network; ask for approval in a sandbox.
Tooling
| Stack | Default |
|---|---|
| Vite (React, Vue, Svelte, Solid) | vite-plugin-pwa (Workbox under the hood) |
| Next.js | Serwist (@serwist/next), the maintained successor to next-pwa |
| Nuxt | @vite-pwa/nuxt |
| SvelteKit | Built-in src/service-worker.js with $service-worker asset lists |
| Other / custom build | workbox-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
idonce 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
| Request | Strategy | Notes |
|---|---|---|
| Hashed build assets (JS, CSS, fonts) | Precache | Versioned by the build; served instantly |
| HTML navigations | Network-first with timeout (3 s) → cached page → offline fallback | Keeps content fresh; or precache the SPA shell |
| Images, avatars | Stale-while-revalidate or cache-first with expiration (max entries, max age) | Always cap entries |
| Public, slowly changing API (catalog) | Stale-while-revalidate | Show cached, refresh in background |
| Authenticated or personal API data | Network-only, or network-first into a per-user cache cleared on logout | Never shared caches |
| POST/PUT writes | Network-only + Background Sync queue if offline writes are required | Idempotency keys on the server |
| Third-party opaque responses | Avoid caching or cap tightly | Opaque 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
- New SW installs and waits while old tabs are open.
- The page detects the waiting worker and shows “Update available, reload”.
- On click, post
SKIP_WAITING; oncontrollerchange, 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.jswithCache-Control: no-cacheso 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-syncBackgroundSyncPlugin) 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.jscannot control/. Serve it at the root or setService-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-CookieorVary: Cookiecontent 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 checknavigator.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.yaml
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.mjs
#!/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.