web-pwa-service-workers-c9e732 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-pwa-service-workers-c9e732 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Quick Guide: A service worker is a programmable network proxy with its own lifecycle: install precaches, activate cleans up, fetch intercepts. Match the caching strategy to the content — cache-first for hashed assets, network-first for HTML, stale-while-revalidate for non-critical API reads. Two details cause most bugs: a response body can be read once, socache.put(request, response.clone()), and a new worker waits until every tab using the old one closes, so updates need explicit detection and a user-triggeredskipWaiting.
Detailed Resources:
it cache-first, and skip navigation preload; there is nothing to preload.
network-first with a timeout and an offline fallback, and enable navigation preload so the request starts before the worker boots. Follow examples/caching.md Pattern 8.
<critical_requirements>
Wrap every async lifecycle task in `event.waitUntil()`. It keeps the worker alive until the promise settles; without it the browser is free to terminate mid-precache.
Put a version in every cache name and delete the non-current ones during activate. That is what makes an upgrade clean and keeps storage from growing without bound.
Clone before caching — `cache.put(request, response.clone())`. A response body can be consumed once, so caching the original leaves the client with an empty response.
Let the client decide when a waiting worker takes over. Detect the waiting worker, tell the user, and call skipWaiting() in response to their message, so behaviour never changes underneath an open session.
Give every fetch path a fallback. A precached offline.html for navigations and a constructed Response as the last resort turn a network failure into a page you wrote.
</critical_requirements>
Auto-detection: navigator.serviceWorker, serviceWorker.register, ServiceWorkerGlobalScope, sw.js, sw.ts, self.skipWaiting, clients.claim, event.waitUntil, event.respondWith, event.preloadResponse, caches.open, caches.match, cache.addAll, CacheStorage, navigationPreload, updateViaCache, controllerchange, updatefound, precache
Applies to:
Handled elsewhere:
server said, which is a different lifetime from records a user edits offline
push event is a delivery hook,and what to show is a product decision
<philosophy>
A service worker is a proxy, not a plugin: once installed it sees every request in its scope, and anything it fails to answer, it breaks.
The lifecycle exists to stop two versions running at once. A new worker installs immediately but waits until every client controlled by the old one has gone, so one page never runs half the old assets and half the new ones.
Registration → Download → Install → Waiting → Activate → Fetch
↓ ↓
(skipWaiting) (claim)skipWaiting() and clients.claim() are the two escapes from that guarantee, and both are opt-in for a reason. Reach for them when the user has asked for the update, or when a fix is urgent enough to be worth a mid-session change.
</philosophy>
<patterns>
Register with feature detection, check for updates periodically, and track the waiting worker so the UI can offer the update.
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
updateViaCache: "none", // ask the server for the worker script every time
});
setInterval(() => registration.update(), UPDATE_CHECK_INTERVAL_MS);
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
installing?.addEventListener("statechange", () => {
const isWaiting =
installing.state === "installed" && navigator.serviceWorker.controller;
if (isWaiting) notifyUpdateAvailable();
});
});Full code: examples/core.md
Precache in install, delete superseded caches in activate, and take skipWaiting as a message rather than calling it unconditionally.
self.addEventListener("install", (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHES.static).then((cache) => cache.addAll(PRECACHE_URLS)),
);
});
self.addEventListener("activate", (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
const current: string[] = Object.values(CACHES);
await Promise.all(
names.filter((n) => !current.includes(n)).map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
);
});
self.addEventListener("message", (event: ExtendableMessageEvent) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});Full code: examples/core.md
Four strategies cover almost everything. Which content gets which is in reference.md.
| Strategy | Behaviour |
|---|---|
| Cache-first | Serve the cached copy, fall back to the network |
| Network-first | Race the network against a timeout, then cache |
| Stale-while-revalidate | Serve the cached copy and refresh in background |
| Cache-only / network-only | One source, no fallback |
Each implementation checks response.ok before caching, clones before cache.put, and caps the number of entries.
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone()); // the clone is cached
}
return networkResponse; // the original goes to the clientFull code: examples/core.md; expiry and per-endpoint variants in examples/caching.md
Route by request shape rather than by URL alone: request.mode, request.destination and the pathname each answer a different question. Returning without calling respondWith lets the browser handle the request normally.
self.addEventListener("fetch", (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET") return;
if (url.origin !== location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirst(request, CACHES.pages));
} else if (request.destination === "image") {
event.respondWith(cacheFirstWithLimit(request, CACHES.images, MAX_IMAGES));
} else if (url.pathname.startsWith("/api/")) {
event.respondWith(staleWhileRevalidate(request, CACHES.api));
} else {
event.respondWith(cacheFirst(request, CACHES.static));
}
});Full code: examples/core.md
Precache an offline page in install and serve it when a navigation has no cache entry and no network. Construct a Response as the final resort, so there is no path that ends in the browser's own error page.
if (request.mode === "navigate") {
const offlinePage = await caches.match("/offline.html");
if (offlinePage) return offlinePage;
}
return new Response("<h1>Offline</h1><p>Check your connection.</p>", {
status: 503,
headers: { "Content-Type": "text/html" },
});Full code: examples/core.md
Worker bootup costs roughly 50ms on desktop and 250ms or more on a slow phone, and a network-first navigation pays it before the request even starts. Navigation preload starts the request in parallel with bootup.
// activate
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
// fetch
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
cache.put(event.request, preloadResponse.clone());
return preloadResponse;
}Once enabled, consume event.preloadResponse; calling fetch(event.request) instead makes two network requests for the same resource.
Full code: examples/caching.md
Show the waiting worker to the user, post SKIP_WAITING when they accept, and reload once on controllerchange so the page and the worker agree.
if (registration.waiting) showUpdateBanner();
function applyUpdate() {
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
}
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});Full code: examples/updates.md — version messaging, deferred and idle application, progressive rollout, cache migration
</patterns>
<red_flags>
Breaks at runtime:
event.waitUntil() — the browser may terminate theworker mid-task — wrap the promise
cache.put(request, response) without .clone() — the body is consumed and the client receivesnothing — cache the clone and return the original
response.ok — a 404 or 500 is stored and served back for the life ofthe cache — gate the put
interpolate a version constant
offline.html for navigations — the user gets the browser's error page — precache one ininstall and return it from the fetch error path
fetch(event.request) in a worker with navigation preload enabled — every navigation makes twonetwork requests — consume event.preloadResponse
request.method !== "GET"
Surprising behaviour:
/sw.js controls /, but /scripts/sw.js controls only/scripts/
clients.claim() takes control without reloading, so an open page keeps the old HTML alongsidethe new worker
precached asset does not
development and fails in production
real one
</red_flags>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.