Service Worker Pitfalls & Anti-Patterns¶
Service worker pitfalls are the recurring mistakes that turn an offline-capable PWA into a site that serves stale content, fails to update, fills the disk or breaks navigations. They are hard to catch because the worker persists across deploys, sits between every request and the network, and behaves differently on the first visit than on the hundredth. This page lists 25 of them. Each one gives the symptom you observe, the mechanism behind it (down to the spec step or engine error message), and a fix with production code.
Key takeaways
- Serve
sw.jsfrom a stable URL withCache-Control: no-cache, a JavaScript MIME type, no redirects, and no CDN edge caching. Never version the worker's file name. - Only cache what you can verify. Skip opaque, non-OK, partial (206),
Vary: *andno-storeresponses, and normalize cache keys (query strings,Vary) deliberately. - Call
respondWith()synchronously, always resolve it with aResponse, consumepreloadResponsewhen navigation preload is on, and never answer a navigation with a redirected response. skipWaiting()plus deleted assets breaks long-lived tabs. Keep the previous build's assets, or let the user choose when to reload, and guard every reload against loops.- The worker is stopped whenever it is idle, and it can be bypassed. Never keep state only in globals, and never rely on the worker for security.
- To remove a worker, deploy a kill-switch worker at the same URL, or send
Clear-Site-Data: "storage"from the network. Deletingsw.jsdoes nothing.
How to use this page¶
Each entry follows the same shape: Symptom (what you or your users see), Cause (why it happens) and Fix (what to change, with code). Error messages are quoted from Chromium and Firefox source code where they help you search your logs.
| Area | Pitfalls |
|---|---|
| Serving and registering | Long max-age on sw.js, Versioned worker URLs, Wrong MIME type or redirects, Scope misconfiguration, CDNs serving the worker, Mixed content |
| Caching | Precaching too much, Opaque responses, Error responses, Old caches, Query strings, Vary, Range requests |
| Fetch handling | Async respondWith, Unused preloadResponse, Redirected navigations, only-if-cached, Late event listeners |
| Lifecycle and updates | skipWaiting and lazy chunks, Reload loops, Global state |
| Security and operations | Security by service worker, Stale content debugging, Kill switch, Clear-Site-Data |
Serving and registering the worker¶
Long max-age on sw.js¶
Symptom. You deploy a new worker, but some users keep running the old one for hours or days. registration.update() reports nothing new. The problem shows up mostly behind corporate proxies, on one CDN region, or for code in importScripts() files.
Cause. Browsers no longer use the HTTP cache for the top-level script by default. Since Chrome 68, update checks for sw.js bypass it (updateViaCache: "imports" is the spec default), and a registration whose last update check is more than 86,400 seconds old is treated as stale, which forces a network check. Long max-age values still bite in four places:
- Shared caches. The browser's revalidation request goes to your CDN or a proxy. If the CDN has its own TTL for
sw.js, it keeps answering with the old bytes, whatever the browser asks. - Imported scripts. Under the default
"imports",importScripts()URLs may be served from the HTTP cache during update checks. An unversioned/sw-lib.jswithmax-age=31536000is effectively frozen. updateViaCache: "all". Registrations that opted into this honormax-agefor the main script as well (capped by the 24-hour staleness rule).- Old browsers. Before Chrome 68,
max-ageabove 86,400 was clamped to 86,400, so a year-longmax-agestill meant a day of staleness.
Fix. Serve the worker and any unfingerprinted imports with Cache-Control: no-cache (store it, but revalidate every time), make the CDN bypass or revalidate it, and purge it on deploy:
location = /sw.js {
# Revalidate on every update check; ETag/Last-Modified make that cheap.
add_header Cache-Control "no-cache" always;
# CDN-specific TTL (RFC 9213 targeted cache control); honored by CDNs that support it.
add_header CDN-Cache-Control "no-store" always;
default_type text/javascript;
types { }
# Never fall back to index.html for the worker.
try_files $uri =404;
}
import express from "express";
import path from "node:path";
const app = express();
const DIST = path.resolve("dist");
app.get("/sw.js", (req, res) => {
res.set({
"Cache-Control": "no-cache",
"CDN-Cache-Control": "no-store",
"Content-Type": "text/javascript; charset=utf-8",
});
res.sendFile(path.join(DIST, "sw.js"));
});
// Fingerprinted assets can be cached forever; the worker cannot.
app.use("/assets", express.static(path.join(DIST, "assets"), { immutable: true, maxAge: "1y" }));
Verify from outside, and from more than one network if you use a CDN:
curl -sI https://example.com/sw.js | grep -iE '^(HTTP|cache-control|cdn-cache-control|content-type|age|location|etag)'
# Expect: HTTP/2 200, cache-control: no-cache, content-type: text/javascript,
# no Location header, and an Age header that stays near 0 across requests.
HTTP Caching & Service Workers explains how the HTTP cache and Cache Storage interact, and Updating Service Workers covers the whole update check.
Versioned service worker URLs (sw-v2.js)¶
Symptom. You renamed the worker to sw-v2.js (or sw.3f9a1c.js) and updated the registration code, but returning users never get it. Some users are stuck on the old version permanently, especially after the old file was deleted from the server.
Cause. This is a chicken-and-egg problem:
- The old worker serves the old, cached HTML.
- That HTML contains the old
register("/sw-v1.js")call, which is byte-identical to the installed worker, so nothing updates. - The browser's own update checks (on every in-scope navigation, and on functional events or subresource fetches once the registration is more than 24 hours stale; there is no periodic timer) fetch the registration's current script URL,
sw-v1.js. If you deleted it, the check fails ("A bad HTTP response code (404) was received when fetching the script.") and the old worker simply keeps running. A failed update never unregisters an existing worker.
sequenceDiagram
participant U as Returning user
participant SW1 as Worker sw-v1.js
participant S as Server
U->>SW1: GET /
SW1-->>U: cached index.html (registers sw-v1.js)
U->>S: update check GET /sw-v1.js
S-->>U: 404 (file deleted)
Note over U,SW1: Update fails, sw-v1.js stays active indefinitely Fix. Use one stable URL (/sw.js), and put the version inside the file (a constant, the precache manifest). If versioned URLs are already in production, rescue those users by answering every old worker URL with the new code through an internal rewrite, never a redirect (worker scripts must not redirect):
# Old versioned worker URLs get the current worker's bytes, so update checks
# against the old URL find a byte difference and install the new code.
location ~ ^/sw-v\d+\.js$ {
rewrite ^ /sw.js last; # internal rewrite, NOT return 301
}
The new code installs at the old URL. Once it controls the page and the page's current HTML calls register("/sw.js") for the same scope, the registration switches to the stable URL. A register() call with a different script URL for an existing scope runs the Update algorithm with the new URL.
Wrong MIME type, missing file or redirects¶
Symptom. register() rejects with a SecurityError or TypeError, and the console shows one of these messages:
Failed to register a ServiceWorker for scope ('https://example.com/') with script
('https://example.com/sw.js'): The script has an unsupported MIME type ('text/html').
... The script does not have a MIME type.
... The script resource is behind a redirect, which is disallowed.
... A bad HTTP response code (404) was received when fetching the script.
Failed to register/update a ServiceWorker for scope ‘https://example.com/’:
Bad Content-Type of ‘text/html’ received for script ‘https://example.com/sw.js’.
Must be a JavaScript MIME type.
Failed to register/update a ServiceWorker for scope ‘https://example.com/’:
Load failed with status 404 for script ‘https://example.com/sw.js’.
Cause. The Update algorithm extracts the MIME type from the response, ignoring parameters, and rejects anything that is not a JavaScript MIME type. Chromium explicitly disables MIME sniffing for worker scripts. The top-level fetch also uses redirect mode "error". The usual culprits:
- SPA fallback. A rewrite rule such as
/* → /index.html 200answers/sw.jswith HTML when the file is missing from the deploy (wrong output directory, build step skipped). - Server MIME maps that serve
.jsor.mjsasapplication/octet-streamortext/plain. - Redirects:
http:tohttps:, adding a trailing slash, locale prefixes,www.canonicalization, or authentication redirects to a login page.
Fix. Serve the real file at its final URL with text/javascript (the MIME type HTML recommends; application/javascript is also accepted), exclude it from SPA fallbacks, and make registration failures visible:
export async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
try {
const registration = await navigator.serviceWorker.register("/sw.js");
return registration;
} catch (error) {
// Report it: registration failures are otherwise silent in production.
globalThis.reportError?.(error);
console.error(`[sw] ${error.name}: ${error.message}`);
}
}
// Serve index.html only for GET requests that accept HTML and have no file
// extension. /sw.js, /assets/app.js and /favicon.ico fall through to a real 404.
app.use((req, res, next) => {
if (req.method !== "GET" || path.extname(req.path) || !req.accepts("html")) return next();
res.sendFile(path.join(DIST, "index.html"));
});
Static hosts differ here. Netlify, for example, does not apply a /* /index.html 200 rewrite when a file exists at the requested path, so on Netlify an HTML response for /sw.js means the file is missing from the deploy.
Scope misconfiguration¶
Symptom. One of these:
- Registration fails: "The path of the provided scope ('/') is not under the max scope allowed ('/js/'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope."
- Registration succeeds, but
navigator.serviceWorker.controllerstaysnullon some pages, andnavigator.serviceWorker.readynever resolves on them. - A worker for
/appalso controls/application-form/. /app(no trailing slash) is uncontrolled, while/app/is controlled.
Cause.
- The default scope is the script's directory, and the max scope is that directory too.
/js/sw.jscannot control/unless the script response sendsService-Worker-Allowed: /. readyresolves only when an active worker's registration matches the current page's URL. On a page outside every scope, it waits forever.- Scope matching is a string prefix test, not a path-segment test (the spec's Match Service Worker Registration), so
/appmatches/application-form/. - The URL
/appdoes not start with/app/, so it is outside that scope.
Fix. Put sw.js at the root of the scope, end scopes with /, and redirect slashless URLs on the server:
// sw.js lives at /sw.js, so the max scope is "/" and no header is needed.
await navigator.serviceWorker.register("/sw.js", { scope: "/app/" });
# If the worker must live in /js/, widen its max scope explicitly.
location = /js/sw.js {
add_header Service-Worker-Allowed "/" always;
add_header Cache-Control "no-cache" always;
}
# Make /app canonical as /app/ so it falls inside the /app/ scope.
location = /app { return 301 /app/; }
Never await navigator.serviceWorker.ready on pages that might be out of scope without a timeout. Registration & Scope covers the algorithm in full.
CDNs serving the service worker¶
Symptom. Registering a worker hosted on a CDN domain fails immediately. Or, with the CDN in front of your own domain: stale workers after deploys, users flip-flopping between two versions, registration failures after enabling a CDN "optimization", or update failures during origin outages.
Failed to register a ServiceWorker: The origin of the provided scriptURL
('https://cdn.example.net') does not match the current origin ('https://example.com').
Cause.
- The worker must be same-origin with the page. The spec says so bluntly: "service workers cannot be hosted on CDNs. But they can include resources via importScripts()."
- A CDN in front of your origin caches
sw.jswith its own TTL. Different points of presence can hold different versions, so the same user alternates between them. Each flip is a byte change, and each one triggers a new install. - Edge features that rewrite responses (redirects, HTML-to-JS injection, bot challenges that return an HTML page with status 200 or 403) break the MIME, status or redirect rules above.
- A CDN error page returned during an origin outage makes update checks fail. This is harmless, but it looks alarming in logs.
Fix.
- Serve
sw.jsfrom the page's origin. If the CDN fronts the whole origin, that is fine: add a cache rule for/sw.js(bypass, or TTL 0 with revalidation), purge it on every deploy, and exempt it from bot challenges and content transformations. - Load third-party code into the worker by bundling it (preferred), or with
importScripts()from the CDN in a classic worker. Pin the version in the URL, because an import's bytes changing on the CDN changes your worker. - After a deploy, check the headers from several regions (
curlthrough different resolvers or a multi-region checker), and compareETagvalues.
Mixed content from the worker¶
Symptom. Requests that work from the page fail only when the worker handles them. cache.addAll() rejects during install because one URL starts with http://. A fetch("http://...") inside the worker rejects with a TypeError.
Cause. A service worker is always a secure context, so every request it issues itself is subject to mixed-content blocking. fetch() requests are blockable and are not auto-upgraded. Requests that the page makes can be upgraded (for example by the page's upgrade-insecure-requests policy) before the worker sees them, but URLs the worker constructs, such as a hard-coded precache list or API base URLs from configuration, are checked in the worker's own context.
Fix. Use https: everywhere, including in build-time manifests and runtime configuration. Validate at build time:
import { readFile } from "node:fs/promises";
const manifest = JSON.parse(await readFile("dist/precache-manifest.json", "utf8"));
const insecure = manifest.filter((entry) => entry.url.startsWith("http://"));
if (insecure.length) {
console.error("Insecure URLs in precache manifest:", insecure.map((e) => e.url));
process.exit(1); // fail the build instead of failing every install in production
}
http://localhost is a potentially trustworthy origin, so this bug often passes local testing and appears only in production.
Caching mistakes¶
Precaching too much¶
Symptom. Installation takes a long time or fails intermittently on mobile networks, so the new worker never activates for some users. QuotaExceededError appears. Users on metered connections see large background downloads right after their first visit.
Cause.
cache.addAll()is atomic. The spec rejects the whole batch if any response is not OK, and theinstallevent fails with it. Each extra URL is one more chance to fail.- Every user downloads the full precache on first install, and again for every changed revision, whether or not they ever visit those routes.
- Install runs in parallel with the first page load, so a large precache competes with the page for bandwidth.
- Storage is finite and can be evicted. See Storage Quotas & Persistence.
Fix. Precache the app shell only: the offline page, core HTML, JS and CSS, and critical fonts and icons. Runtime-cache everything else with limits. Warm optional assets after activation on a best-effort basis, register the worker after the page has loaded, and enforce a size budget in CI:
const PRECACHE = "app-precache-v42";
const CRITICAL = ["/", "/offline.html", "/assets/app.4d1c.js", "/assets/app.9e2a.css"];
const OPTIONAL = ["/assets/help-illustrations.7b3f.webp", "/assets/fonts/extra.woff2"];
self.addEventListener("install", (event) => {
// Atomic on purpose: the app is useless without these.
event.waitUntil(caches.open(PRECACHE).then((cache) => cache.addAll(CRITICAL)));
});
// Best effort, after the worker is live. Not in activate: fetch events for
// controlled pages wait while the worker is "activating", so a slow download
// in activate's waitUntil() would stall every request.
self.addEventListener("message", (event) => {
if (event.data?.type !== "WARM_OPTIONAL") return;
event.waitUntil(
caches.open(PRECACHE).then(async (cache) => {
const missing = [];
for (const url of OPTIONAL) if (!(await cache.match(url))) missing.push(url);
// allSettled: one failed download never affects the others.
await Promise.allSettled(missing.map((url) => cache.add(url)));
}),
);
});
// Don't compete with the first load for bandwidth and CPU.
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
await navigator.serviceWorker.register("/sw.js");
const { active } = await navigator.serviceWorker.ready;
// Skip optional downloads on metered or constrained connections.
const connection = navigator.connection;
if (!connection?.saveData && !/2g/.test(connection?.effectiveType ?? "")) {
active?.postMessage({ type: "WARM_OPTIONAL" });
}
} catch (error) {
console.error("[sw] registration failed", error);
}
});
}
The optional warm-up runs from a message instead of activate for a specific reason: the spec's Handle Fetch algorithm waits while the active worker's state is "activating", so every request from a controlled page stalls until activate's waitUntil() promise settles. Keep activate to fast, local work such as deleting caches.
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
const BUDGET_BYTES = 1.5 * 1024 * 1024; // pick a number and defend it
const manifest = JSON.parse(await readFile("dist/precache-manifest.json", "utf8"));
let total = 0;
for (const { url } of manifest) total += (await stat(path.join("dist", url))).size;
console.log(`precache: ${(total / 1024).toFixed(0)} KiB in ${manifest.length} files`);
if (total > BUDGET_BYTES) {
console.error(`precache exceeds budget of ${(BUDGET_BYTES / 1024).toFixed(0)} KiB`);
process.exit(1);
}
Precaching & Runtime Caching covers manifest generation and revisioning.
Blindly caching opaque responses¶
Symptom. Storage usage reported by navigator.storage.estimate() or DevTools is hundreds of megabytes for a small site, and quota errors follow. A third-party script or font is broken offline "forever". Some requests fail with:
The FetchEvent for "https://cdn.example.net/lib.js" resulted in a network error response:
an "opaque" response was used for a request whose type is not no-cors
Cause. A cross-origin request in no-cors mode (for example, an <img> or classic <script> without crossorigin) produces an opaque response: status is 0, headers are hidden, and the body is unreadable. As a result:
- You cannot tell success from failure. A 404 or 500 looks exactly like a 200. Cache-first on an opaque response can pin an error permanently.
- Quota padding. To avoid leaking the size of cross-origin resources, Chromium charges each opaque response a random padding against your quota. The current implementation draws it from 0 up to 14,431 KiB (about 14 MB,
kPaddingRangeinpadding_key.cc), roughly 7 MB on average. That average is the "7 MB per opaque response" figure in Chrome's Workbox documentation. A hundred cached avatars can therefore count as hundreds of megabytes. - Mode mismatches. Fetch forbids using an opaque response for a request whose mode is not
no-cors, which coversfetch()calls, module scripts andcrossoriginelements.
Fix. Request cross-origin resources in CORS mode, so responses are readable and unpadded: add crossorigin="anonymous" to the elements, and make the CDN send Access-Control-Allow-Origin. Then cache only verifiable responses:
// One gate for every cache.put() in the worker.
export function isCacheable(request, response) {
if (request.method !== "GET") return false; // "Request method 'POST' is unsupported"
const { protocol } = new URL(request.url);
if (protocol !== "https:" && protocol !== "http:") return false; // chrome-extension:, data:, ...
if (!response || response.type === "error") return false;
if (response.type === "opaque" || response.type === "opaqueredirect") return false; // unverifiable
if (!response.ok || response.status === 206) return false; // errors and partial content
const vary = response.headers.get("Vary") ?? "";
if (vary.split(",").some((v) => v.trim() === "*")) return false; // put() would reject
const cacheControl = response.headers.get("Cache-Control") ?? "";
if (/\bno-store\b/i.test(cacheControl)) return false; // respect the server's intent
return true;
}
If you truly must cache opaque responses (third-party images you cannot make CORS-enabled), use a strategy that keeps refreshing them, such as stale-while-revalidate or network-first, never cache-first. Add a small maxEntries limit and a separate cache so you can drop it wholesale.
Caching error and non-OK responses¶
Symptom. Users keep seeing a "502 Bad Gateway" page, a 404, a JSON error body, or a Wi-Fi captive-portal login page long after the underlying problem is fixed, often only in the installed app, and sometimes even online.
Cause.
fetch()resolves for HTTP errors. Only network failures reject.cache.put()accepts any status except 206. Per the spec it rejects only for non-GET methods, non-HTTP(S) schemes, status 206,Vary: *, and bodies that are already used.add()/addAll()reject non-OK responses, but a hand-written runtime strategy that callsput()does not.- Captive portals and misconfigured proxies answer every URL with
200 OKand an HTML page, so evenresponse.okis true.
Fix. Gate every put() through a single check (the isCacheable() function above), and add a plausibility check on the content type for the request's destination:
import { isCacheable } from "./sw-cacheable.js";
// Captive portals and bad proxies return 200 HTML for everything.
function looksRight(request, response) {
if (response.redirected && new URL(response.url).origin !== self.location.origin) return false;
const type = response.headers.get("Content-Type") ?? "";
switch (request.destination) {
case "script": return /javascript|ecmascript/i.test(type);
case "style": return /text\/css/i.test(type);
case "image": return /^image\//i.test(type);
case "font": return /font|woff|otf|ttf/i.test(type);
default: return true;
}
}
async function staleWhileRevalidate(event, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
const network = fetch(event.request).then((response) => {
if (isCacheable(event.request, response) && looksRight(event.request, response)) {
// Clone before the page reads the body. Don't await put(): it resolves only
// after the whole body is stored, which would delay a cache-miss response.
// waitUntil() here is legal because the waitUntil() below is still pending.
event.waitUntil(
cache.put(event.request, response.clone()).catch((error) => {
console.warn("[sw] cache write failed", error); // e.g. QuotaExceededError
}),
);
}
return response; // error responses reach the page but never the cache
});
event.waitUntil(network.catch(() => {})); // keep the worker alive for the revalidation
return cached ?? network;
}
For API responses, apply the same rule to the payload: never cache a body your code would treat as an error. Serve an explicit offline response instead. See Offline UX & Fallbacks.
Never cleaning up old caches¶
Symptom. Storage usage grows with every release, and quota errors appear eventually. After a deploy, some assets come from the previous version even though the new worker is active.
Cause.
- Cache Storage never expires anything. Caches and entries persist until you delete them (or the browser evicts the whole origin).
caches.match()without acacheNamesearches the origin's caches in creation order and returns the first hit (spec §5.5.1). An oldv41cache that still holds/or/app.jsshadows the fresh copy inv42.- Runtime caches without limits grow with every page visited.
Fix. Delete obsolete caches in activate (it runs once the old worker is gone), scoped to your own name prefix because Cache Storage is shared by every worker on the origin. Pass cacheName to match(), and bound runtime caches:
const NS = "app";
const VERSION = "v42";
const PRECACHE = `${NS}-precache-${VERSION}`;
const RUNTIME = { images: `${NS}-images`, api: `${NS}-api` };
const KEEP = new Set([PRECACHE, ...Object.values(RUNTIME)]);
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
// Only our namespace: other registrations on this origin share Cache Storage.
if (name.startsWith(`${NS}-`) && !KEEP.has(name)) await caches.delete(name);
}
})(),
);
});
// Look up precached files in the current precache only.
const fromPrecache = (request) => caches.match(request, { cacheName: PRECACHE });
// Cache.keys() returns entries in insertion order, and put() on an existing key
// removes and re-appends it, so this evicts the least recently written entries.
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
for (let i = 0; i < keys.length - maxEntries; i++) await cache.delete(keys[i]);
}
Call trimCache() inside event.waitUntil() after runtime writes. Workbox handles cleanup and expiration for you (cleanupOutdatedCaches(), ExpirationPlugin). If you run several workers per origin, read the section on multiple registrations in Advanced Techniques.
Query strings in cache keys¶
Symptom. Offline navigation to /?utm_source=newsletter shows the browser's offline error even though / is precached. The runtime cache fills with near-duplicates (/post?id=7&ref=x, /post?id=7&ref=y), and cache-busting parameters (?v=1699999999) add a new entry on every deploy.
Cause. Cache matching compares the full URL, including the query and excluding only the fragment, unless you pass ignoreSearch: true. That option is a blunt instrument: it ignores all parameters, including meaningful ones like ?id=7. In Chromium it also disables the keyed lookup. CacheStorageCache::QueryCache iterates over every entry in the cache when ignore_search is set.
Fix. Normalize cache keys yourself, and use the normalized key for both put() and match():
const TRACKING_PARAMS = [/^utm_/, /^fbclid$/, /^gclid$/, /^msclkid$/, /^mc_(?:cid|eid)$/, /^_ga$/];
export function cacheKeyFor(request) {
const url = new URL(request.url);
for (const name of [...url.searchParams.keys()]) {
if (TRACKING_PARAMS.some((re) => re.test(name))) url.searchParams.delete(name);
}
url.searchParams.sort(); // ?b=1&a=2 and ?a=2&b=1 become one entry
url.hash = "";
return url.href;
}
// Usage inside a strategy:
// const key = cacheKeyFor(event.request);
// const hit = await cache.match(key);
// ...
// await cache.put(key, response.clone());
For precaching, Workbox's ignoreURLParametersMatching option (default [/^utm_/, /^fbclid$/]) does the same thing. Remove cache-busting parameters from your build, and use fingerprinted file names instead.
Vary header matching surprises¶
Symptom. cache.match() returns undefined although DevTools shows the entry in Cache Storage. Matching works for some callers and not others. Or cache.put() rejects with "Vary header contains *".
Cause. The Cache API honors Vary. The spec's Request Matches Cached Item compares, for every header named in the cached response's Vary, the value on the stored request with the value on the query request. Any difference means no match:
| Cached response header | What goes wrong |
|---|---|
Vary: Accept | Precached with cache.add("/") (stored Accept: */*), but a navigation asks with Accept: text/html,..., so the entry never matches |
Vary: Cookie or Vary: Authorization | Misses as soon as any cookie or token changes |
Vary: User-Agent | Misses after every browser update |
Vary: * | Never matches, and put()/addAll() reject with TypeError |
Vary: Accept-Encoding | Harmless. Accept-Encoding is added by the network layer and appears on neither request |
Fix. Choose one of these:
- Match with
{ ignoreVary: true }where you control what was stored, for example for precache lookups. -
Store a normalized copy without
Vary, if the variation doesn't matter for your offline use:sw.js// Rebuild the response without Vary (not possible for opaque responses). async function withoutVary(response) { const headers = new Headers(response.headers); headers.delete("Vary"); return new Response(await response.blob(), { status: response.status, statusText: response.statusText, headers, }); } -
Fix the server. Content negotiation on
Acceptfor HTML documents is rarely needed.
Not handling Range requests¶
Symptom. Audio or video served through the worker won't seek, restarts from the beginning, won't play in some browsers, or re-downloads while offline. Runtime caching of media throws:
Failed to execute 'put' on 'Cache': Partial response (status code 206) is unsupported
Cause. Media elements request byte ranges (Range: bytes=1048576-), and servers answer with 206 Partial Content. The spec forbids storing 206 responses, so a network-then-cache strategy fails. Answering a Range request with a cached full 200 confuses media pipelines that expect a 206 with a Content-Range header.
Fix. Cache the whole file explicitly: at install, or when the user taps "Download for offline". Then answer Range requests by slicing the cached body. Chrome's Workbox documentation also recommends adding crossorigin="anonymous" to <video> and <audio>, even for same-origin URLs.
// Build a 206 (or 416) from a full cached response, following RFC 9110 range semantics.
export async function rangeResponse(request, full) {
const header = request.headers.get("Range");
if (!header) return full;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match || (match[1] === "" && match[2] === "")) return full; // multi-range/garbage: ignore Range
const blob = await full.blob();
const size = blob.size;
let start;
let end;
if (match[1] === "") {
const suffix = Number(match[2]); // bytes=-500: last 500 bytes
if (suffix === 0) return unsatisfiable(size);
start = Math.max(size - suffix, 0);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1);
}
if (start >= size || start > end) return unsatisfiable(size);
const body = blob.slice(start, end + 1);
const headers = new Headers(full.headers);
headers.set("Content-Range", `bytes ${start}-${end}/${size}`);
headers.set("Content-Length", String(body.size));
headers.set("Accept-Ranges", "bytes");
return new Response(body, { status: 206, statusText: "Partial Content", headers });
}
function unsatisfiable(size) {
return new Response(null, {
status: 416,
statusText: "Range Not Satisfiable",
headers: { "Content-Range": `bytes */${size}` },
});
}
import { rangeResponse } from "./sw-range.js";
const MEDIA = "app-media";
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith("/media/")) return;
event.respondWith(
(async () => {
// Look up by URL: the stored request had no Range header.
const full = await caches.match(url.pathname, { cacheName: MEDIA, ignoreVary: true });
return full ? rangeResponse(event.request, full) : fetch(event.request);
})(),
);
});
// Called from a "Download for offline" button through postMessage.
async function saveForOffline(path) {
const cache = await caches.open(MEDIA);
await cache.add(path); // no Range header: a full 200 response is stored
}
Workbox packages the same logic as workbox-range-requests. For large media, check storage first (navigator.storage.estimate()), and consider Background Fetch where it is available.
Fetch handler mistakes¶
Async respondWith() mistakes¶
Symptom. Requests intermittently bypass the worker, fail with a network error, or log one of these:
Uncaught (in promise) InvalidStateError: Failed to execute 'respondWith' on 'FetchEvent':
The event handler is already finished.
InvalidStateError: ... respondWith() was already called.
The FetchEvent for "https://example.com/data.json" resulted in a network error response:
an object that was not a Response was passed to respondWith().
... a Response whose "bodyUsed" is "true" cannot be used to respond to a request.
... the promise was resolved with an error response object.
Cause. The spec's respondWith(r) throws InvalidStateError unless the event's dispatch flag is set, which means the call happens synchronously while listeners run, and it throws again if respondWith() was already entered. If r fulfills with anything that is not a Response, including undefined from a cache miss, the fetch gets a network error. A Response whose body was already read cannot be used.
Fix. Call respondWith() synchronously with a promise, decide whether to respond from synchronous information (URL, method, mode), and always resolve with a Response:
// ❌ respondWith after an await: the dispatch has ended -> InvalidStateError.
self.addEventListener("fetch", async (event) => {
const cached = await caches.match(event.request);
event.respondWith(cached ?? fetch(event.request));
});
// ❌ Resolves with undefined on a cache miss -> network error.
self.addEventListener("fetch", (event) => {
event.respondWith(caches.match(event.request));
});
// ❌ Reads the body for the cache, then returns the same (used) Response.
self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(event.request).then(async (response) => {
const cache = await caches.open("app-runtime");
await cache.put(event.request, response); // consumes the body
return response; // bodyUsed === true -> network error
}),
);
});
// ✅ Synchronous decision, one async function, always a Response, clone before caching.
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // let the browser handle it
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
event.respondWith(
(async () => {
const cached = await caches.match(request, { cacheName: "app-runtime" });
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const copy = response.clone();
event.waitUntil(caches.open("app-runtime").then((c) => c.put(request, copy)));
}
return response;
} catch {
return (await caches.match("/offline.html")) ?? Response.error();
}
})(),
);
});
An async listener function is harmless in itself (the browser ignores its return value), but it tempts you to await before respondWith(). It also does not extend the event's lifetime. Only waitUntil() and respondWith() do. The Handling Fetch Events page covers the full algorithm.
Forgetting to consume preloadResponse¶
Symptom. With navigation preload enabled, the server logs two requests per navigation (one carrying Service-Worker-Navigation-Preload: true), time to first byte doesn't improve, and Chromium warns:
The service worker navigation preload request was cancelled before 'preloadResponse'
settled. If you intend to use 'preloadResponse', use waitUntil() or respondWith() to
wait for the promise to settle.
Cause. Once registration.navigationPreload.enable() has run, the browser starts the navigation request in parallel with booting the worker. If your handler answers from cache or calls fetch(event.request) again, the preload is wasted (a duplicate request) and gets cancelled when the event finishes.
Fix. On navigations, use the preload response first, and let it settle even when you answer from somewhere else. Or disable preload if your navigation strategy is cache-first:
self.addEventListener("activate", (event) => {
event.waitUntil(self.registration.navigationPreload?.enable());
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
try {
const preloaded = await event.preloadResponse; // undefined if preload is off
if (preloaded) return preloaded;
return await fetch(event.request);
} catch {
return (await caches.match("/offline.html")) ?? Response.error();
}
})(),
);
});
// Cache-first navigations? Then preload only costs you a request:
// self.registration.navigationPreload.disable();
// If some routes answer from cache while preload stays on, at least let it settle:
// event.waitUntil(event.preloadResponse.catch(() => {}));
The header value, server-side Vary handling and support details are on Navigation Preload.
Redirected responses used for navigations¶
Symptom. Navigating to a page such as /account shows the browser's error page, but only while the worker is active:
The FetchEvent for "https://example.com/account" resulted in a network error response:
a redirected response was used for a request whose redirect mode is not "follow".
Failed to load ‘https://example.com/account’. A ServiceWorker passed a redirected Response
to FetchEvent.respondWith() while RedirectMode is not ‘follow’.
Cause. Navigation requests use redirect mode "manual", so that the browser, not your worker, follows redirects and updates the address bar. Fetch's HTTP fetch step returns a network error when "request's redirect mode is not "follow" and response's URL list has more than one item", that is, when response.redirected is true. Such responses usually enter the cache through cache.add("/account"), which followed a server redirect to /account/ or /login, and are later served for a navigation.
Fix.
- Precache final URLs (
/account/, not/account). -
When you store a response that may have been redirected, store a clean copy (Workbox calls this
copyResponse):sw.js// A new Response has a single-entry URL list, so it is valid for navigations. async function cleanResponse(response) { if (!response.redirected) return response; return new Response(await response.blob(), { status: response.status, statusText: response.statusText, headers: response.headers, }); } self.addEventListener("install", (event) => { event.waitUntil( (async () => { const cache = await caches.open("app-precache-v42"); for (const url of ["/", "/account/", "/offline.html"]) { const response = await fetch(url, { credentials: "same-origin" }); if (!response.ok) throw new Error(`precache failed for ${url}: ${response.status}`); await cache.put(url, await cleanResponse(response)); } })(), ); }); -
For network pass-through, use
fetch(event.request). Withmanualredirect mode it returns anopaqueredirectresponse, which is exactly what a navigation accepts.
Requests with cache mode only-if-cached¶
Symptom. Errors like this one appear in the worker's console, typically while DevTools is open:
Uncaught (in promise) TypeError: Failed to execute 'fetch' on 'ServiceWorkerGlobalScope':
'only-if-cached' can be set only with 'same-origin' mode
Cause. Some internally generated requests carry cache: "only-if-cached" together with a mode other than same-origin. Re-issuing them with fetch(event.request) fails the Fetch standard's constructor check for that combination.
Fix. Skip such requests before calling respondWith():
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.cache === "only-if-cached" && request.mode !== "same-origin") return;
// ...normal routing
});
Adding event listeners late¶
Symptom. Push, fetch or notification-click handlers work right after install, but stop firing after the browser restarts the worker. Both Chromium and Firefox warn about it:
Event handler of 'fetch' event must be added on the initial evaluation of worker script.
Fetch event handlers must be added during the worker script’s initial evaluation.
Cause. When a worker is installed, browsers record which functional events it handles. Chromium, for example, skips starting the worker for fetches when no fetch handler exists, and it detects a no-op fetch handler and warns: "Fetch event handler is recognized as no-op. No-op fetch handler may bring overhead during navigation. Consider removing the handler if possible." Listeners added after an await, inside a setTimeout, or after asynchronously loading configuration are missing from that initial evaluation. They may work in the same worker instance that added them, then silently disappear the next time the worker starts from scratch, which is after every idle termination.
Fix. Register every listener synchronously at the top level, and do asynchronous setup inside the handlers:
// ❌ Listener registered after an await.
// const config = await (await fetch("/sw-config.json")).json(); // also invalid: TLA
// self.addEventListener("fetch", ...);
// ✅ Listener registered immediately; configuration loaded lazily and memoized.
let configPromise;
const getConfig = () =>
(configPromise ??= caches
.match("/sw-config.json")
.then((r) => r ?? fetch("/sw-config.json"))
.then((r) => r.json())
.catch((error) => {
configPromise = undefined;
throw error;
}));
// Example use of the configuration: route API calls to a configured API origin.
async function rewriteForConfig(request, config) {
const url = new URL(request.url);
const target = new URL(url.pathname + url.search, config.apiOrigin); // e.g. "https://api.example.com"
const hasBody = request.method !== "GET" && request.method !== "HEAD";
return new Request(target, {
method: request.method,
headers: request.headers,
// Buffered: streaming request bodies are Chromium-only.
body: hasBody ? await request.clone().arrayBuffer() : undefined,
credentials: "include",
mode: "cors",
});
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith("/api/")) return;
event.respondWith(
getConfig().then(
async (config) => fetch(await rewriteForConfig(event.request, config)),
// Only a configuration failure falls back; a failed upstream POST is not
// silently re-sent somewhere else.
() => fetch(event.request),
),
);
});
// Also register push, notificationclick, sync, message ... here, at the top level.
A related trap: conditionally registering a listener at top level based on state that differs between worker starts, for example if (self.registration.navigationPreload) addEventListener("fetch", ...) is fine because it is constant, but if (Math.random() < 0.5) or a check of Date.now() is not. The set of handlers must be identical on every evaluation of the script.
Lifecycle and update mistakes¶
skipWaiting() breaking lazy-loaded chunks¶
Symptom. Minutes after a deploy, error monitoring fills up with failures from users who had the app open. They click into a route they had not visited yet, and it never renders. The error text depends on the engine and the bundler:
TypeError: Failed to fetch dynamically imported module: https://example.com/assets/Settings-3f9a1c.js
TypeError: error loading dynamically imported module: https://example.com/assets/Settings-3f9a1c.js
TypeError: Importing a module script failed.
ChunkLoadError: Loading chunk 482 failed.
(error: https://example.com/assets/482.3f9a1c.js)
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded
with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
The first line comes from Chromium, the second from Firefox, the third from Safari, the fourth from webpack's runtime, and the last from Chromium when an SPA fallback answered the chunk request with index.html. A reload fixes it, so nobody can reproduce the reports.
Cause. self.skipWaiting() in install makes a new worker take over pages that the old worker loaded. Those pages keep running old JavaScript, and that JavaScript references old, content-hashed file names. Then three things line up:
- The new worker's
activatehandler deletes the old precache, exactly as the cache cleanup advice says it should. - The new precache contains only the new hashed files, so the old page's request for
Settings-3f9a1c.jsmisses. - The request falls through to the network. The deploy replaced the asset directory, so the old file is gone. The server answers 404 or, worse, an SPA rewrite answers
200 text/htmlwithindex.html.
The same mismatch exists without a service worker, in any tab that stayed open across a deploy. skipWaiting() makes it worse in two ways: it guarantees that every open tab runs under a worker that no longer has the old files, and it deletes the cached copies that would otherwise have rescued the old page. The old page may also send postMessage() commands in a format that the new worker no longer understands.
Fix. Apply all five measures. Each one closes a different gap:
- Don't call
skipWaiting()unconditionally. Activate a new version when the user accepts a reload prompt, or on the next navigation. Updating Service Workers compares the patterns. - Keep old hashed assets on the server for several releases. Content-hashed names never collide, so an append-only asset directory is safe. If you deploy with an object-store or
rsyncsync, don't let it delete files from the asset directory. Expire old files with a lifecycle rule instead. - Keep the previous precache for one generation, and look up hashed assets in every cache.
- Never answer a missing hashed asset with HTML. A fast, honest 404 is recoverable. A
200 text/htmlproduces a confusing MIME error, and any cache that stores it is poisoned. - Recover once in the page. When a lazy import fails, activate the waiting worker if there is one, then reload, with a guard against loops. Updating Service Workers has a complete recovery module for Vite's
vite:preloadErrorevent, webpack'sChunkLoadErrorand bareimport().
const BUILD = 118; // increases with every release; injected by the build
const PRECACHE = `app-precache-${BUILD}`;
const PRECACHE_URLS = ["/", "/offline.html", "/assets/index-3b1f0e.js", "/assets/index-9c2d4a.css"]; // injected
const GENERATIONS = 2; // this release and the previous one the user actually had
const HASHED_DIR = "/assets/"; // the build content-hashes every file name in here
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS)));
// Note: no skipWaiting() here. Activation happens when the user accepts an update.
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Sort the precaches that exist on this device by build number, newest first.
// Release numbers can skip (a user may jump from 112 to 118), so keep the
// newest N caches that exist instead of computing "BUILD - 1".
const precaches = (await caches.keys())
.map((name) => ({ name, build: Number(/^app-precache-(\d+)$/.exec(name)?.[1]) }))
.filter((entry) => Number.isInteger(entry.build))
.sort((a, b) => b.build - a.build);
await Promise.all(precaches.slice(GENERATIONS).map(({ name }) => caches.delete(name)));
})(),
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "GET" || url.origin !== self.location.origin) return;
if (!url.pathname.startsWith(HASHED_DIR)) return;
event.respondWith(
(async () => {
// No cacheName: search every cache, so an old page still gets its old chunk.
// Hashed URLs are immutable, so any hit is the right bytes.
const cached = await caches.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
const type = response.headers.get("Content-Type") ?? "";
if (response.ok && type.includes("text/html")) {
// An SPA fallback answered for a deleted asset. Turn it into an honest 404,
// so the page's import() rejects immediately and recovery code can run.
return new Response(null, { status: 404, statusText: "Stale asset" });
}
return response;
})(),
);
});
# Hashed assets: immutable, and a miss is a real 404, never index.html.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# The SPA fallback applies to everything else.
location / {
try_files $uri /index.html;
}
Workbox precaching has the same exposure in a different shape: it keeps a single precache, and its activate step deletes every entry that is no longer in the new manifest. An old page loses its old chunks the moment the new worker activates. The server-side measures apply regardless of tooling.
Infinite reload loops on controllerchange¶
Symptom. The page reloads by itself. It happens once on the very first visit, losing whatever the user had typed, or it repeats until the user closes the tab. Server logs show bursts of navigations from one client a few hundred milliseconds apart. Sometimes it happens only in production, or only for users behind one CDN region.
Cause. The snippet behind almost every loop is this one:
navigator.serviceWorker.addEventListener("controllerchange", () => {
window.location.reload();
});
controllerchange does not mean "an update is ready". It fires whenever navigator.serviceWorker.controller changes. The spec queues it from Notify Controller Change, which runs in clients.claim() and in the Activate algorithm for every client using the registration. So it fires:
- On the first visit, when a newly installed worker calls
clients.claim()and takes over a page that had no controller. - In every tab, when any tab triggers
skipWaiting(). - On every load, if something produces a "new" worker on every load.
A loop needs a handler that reloads unconditionally, plus a trigger that happens again after the reload. These triggers show up in practice:
| Trigger | Why it repeats |
|---|---|
sw.js bytes differ on every request | A build timestamp computed per request, a CSP nonce or monitoring snippet injected into every text response by an edge function, or CDN points of presence holding different versions. Each navigation's update check installs a "new" worker, which calls skipWaiting() |
| Two scripts registered for one scope | The app registers /sw.js, and a third-party SDK registers its own worker for scope /. A register() call with a different script URL is an update for that scope, so the two keep replacing each other |
A kill switch while pages still call register() | The kill switch unregisters and reloads the page. The reloaded page registers /sw.js again, and the kill switch installs again |
Several controllerchange listeners | A framework plugin and your own code both reload, or one reloads while the other shows a prompt |
The worker can loop on its own, too, by calling self.registration.update() from install, activate or a timer. Chromium throttles this when the calling worker controls no pages: the first call runs immediately, the next is delayed by 30 seconds, each later one waits twice as long, and once the delay would exceed 3 minutes update() rejects with "Service worker self-update limit exceeded." (kSelfUpdateDelay and kMaxSelfUpdateDelay in Chromium's service_worker_registration.cc). The throttle exists because a worker that updates itself from its own events could otherwise keep itself running forever.
Fix. Reload only when this tab asked for the update, only if the page had a controller before, and never twice within a short window. Then remove the triggers: make the worker's bytes deterministic, and register from exactly one place.
// Reload on controllerchange only when it is safe and intended.
const GUARD_KEY = "sw-reload-at";
const GUARD_WINDOW_MS = 10_000;
export function installReloadGuard({ onStaleTab = () => {} } = {}) {
if (!("serviceWorker" in navigator)) return { requestUpdate: async () => false };
// Guard 1: no controller at load means a first-install claim, not an update.
const hadController = Boolean(navigator.serviceWorker.controller);
// Guard 2: only the tab where the user accepted the update reloads itself.
let acceptedHere = false;
let reloading = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!hadController || reloading) return;
if (!acceptedHere) {
onStaleTab(); // other tabs: show "This tab is out of date", don't yank the page
return;
}
// Guard 3: a time window that survives the reload itself.
try {
const last = Number(sessionStorage.getItem(GUARD_KEY)) || 0;
if (Date.now() - last < GUARD_WINDOW_MS) {
console.error("[sw] reload loop detected; not reloading again");
return;
}
sessionStorage.setItem(GUARD_KEY, String(Date.now()));
} catch {
// Storage unavailable (privacy mode, sandboxed frame): the in-memory
// `reloading` flag still prevents a double reload in this document.
}
reloading = true;
window.location.reload();
});
return {
// Call from the "Update now" button. Resolves false if nothing is waiting.
async requestUpdate() {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration?.waiting) return false;
acceptedHere = true;
registration.waiting.postMessage({ type: "SKIP_WAITING" });
return true;
},
};
}
A deploy check catches the most common trigger before users do. It fetches the worker twice, with the header that browsers send, and fails if the bytes differ:
#!/usr/bin/env bash
# Fails when two consecutive requests for the worker return different bytes.
set -euo pipefail
URL="${1:-https://example.com/sw.js}"
first=$(curl -fsS -H 'Service-Worker: script' "$URL" | shasum -a 256)
second=$(curl -fsS -H 'Service-Worker: script' "$URL" | shasum -a 256)
if [[ "$first" != "$second" ]]; then
echo "sw.js is not byte-stable: every update check will install a new worker" >&2
exit 1
fi
echo "sw.js is byte-stable: ${first%% *}"
To find two scripts competing for one scope, log the active script URL on every load: (await navigator.serviceWorker.getRegistration())?.active?.scriptURL. If it alternates between two values, two register() calls are fighting. Several registrations with different scopes are fine. Updating Service Workers shows the complete prompt-driven update flow built around these guards.
Keeping state in global variables¶
Symptom. Behavior that depends on something the worker "remembers" works in testing and fails for real users after a pause. A setting sent with postMessage() reverts to its default, an in-memory queue loses entries, a counter starts again from zero, or an access token that the page handed to the worker disappears and API calls start failing with 401. It is timing-dependent: it breaks after roughly 30 seconds of inactivity, and never while you step through the code in DevTools.
Cause. A service worker's global scope lives only as long as the worker runs, and browsers stop idle workers aggressively. Chromium stops a worker 30 seconds after its last event settles, Firefox 30 seconds after each event, and WebKit shortly after the last page of the origin goes away (details in Advanced Techniques). The next event starts a fresh global scope that runs sw.js again from the top. The worker's lifecycle state stays activated the whole time, so nothing tells your code that it restarted.
Debugging hides the problem. Chromium does not terminate a worker while DevTools is attached to it (a source comment in service_worker_version.cc reads "Basically the service worker won't be terminated if DevTools is attached"), and it cancels request timeouts in that state. WebKit likewise treats an inspected worker as one that must keep running.
Two more properties of globals surprise people:
- One instance serves every client. A global set on behalf of one tab is visible while handling requests from every other tab in scope. Per-tab or per-user values leak across tabs, and across accounts on a shared device.
- Old and new workers have separate globals. During an update, the installing or waiting worker runs its own copy of the script. A message the page sends to
registration.activenever reaches it, and whatever the old worker kept in memory is gone after activation.
Fix. Persist anything that must survive a restart, and treat globals only as caches that you can rebuild at any moment:
// Durable key-value state for the worker, stored in IndexedDB, with an
// in-memory read cache that is safe to lose whenever the worker stops.
const DB_NAME = "sw-state";
const STORE = "kv";
let dbPromise = null;
const memo = new Map(); // cache only: empty after every restart
function openDb() {
dbPromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE);
request.onsuccess = () => {
const db = request.result;
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
request.onerror = () => {
dbPromise = null;
reject(request.error);
};
});
return dbPromise;
}
async function run(mode, operation) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, mode);
const request = operation(tx.objectStore(STORE));
tx.oncomplete = () => resolve(request.result);
tx.onerror = tx.onabort = () => reject(tx.error);
});
}
export async function getState(key, fallback) {
if (memo.has(key)) return memo.get(key);
const value = (await run("readonly", (store) => store.get(key))) ?? fallback;
memo.set(key, value);
return value;
}
export async function setState(key, value) {
await run("readwrite", (store) => store.put(value, key));
memo.set(key, value); // update the cache only after the write committed
}
import { getState, setState } from "./sw-state.js";
// ❌ let preferCache = false; // silently resets whenever the worker restarts
self.addEventListener("message", (event) => {
if (event.data?.type !== "SET_PREFER_CACHE") return;
// waitUntil(): the write must finish even if the worker is about to idle out.
event.waitUntil(setState("preferCache", Boolean(event.data.value)));
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
const preferCache = await getState("preferCache", false);
const cached = await caches.match(event.request, { cacheName: "app-pages" });
if (preferCache && cached) return cached;
try {
return await fetch(event.request);
} catch {
return cached ?? (await caches.match("/offline.html")) ?? Response.error();
}
})(),
);
});
Rules that follow:
- Never make the worker the only holder of a secret. If the worker needs a token, keep it where the worker can read it again after a restart (IndexedDB, or an
HttpOnlycookie that the browser attaches for you), and coordinate refreshes with a lock as shown in Advanced Techniques. - Keep per-user data out of globals entirely, or key it by user and clear it on sign-out.
- Test with the worker stopped. In Chromium, click Stop for the worker in
chrome://serviceworker-internals(or wait more than 30 seconds with DevTools closed), then repeat the action. In Firefox,about:debugging#/runtime/this-firefoxlists running workers. Automated Testing shows how to force restarts in end-to-end tests.
Security and operations mistakes¶
Relying on the service worker for security¶
Symptom. One of these turns up in a security review or an incident:
- Access control lives in the fetch handler ("block
/admin/unless the role cookie says admin", "strip thedebugparameter", "add the CSRF header"), and the server trusts that the worker did its job. - After one person signs out on a shared computer, the next person opens the app offline and sees the previous user's cached inbox.
- An attacker who found an XSS bug or an upload hole registered their own service worker on your origin, and it keeps serving their code after you fixed the bug.
Cause.
- The worker is optional, and it can be bypassed. It isn't installed on the first visit. A hard reload loads the page without it (Lifecycle). DevTools has a Bypass for network switch. Static Routing rules can send requests past it, and any HTTP client that isn't a browser never meets it. It also runs on the user's device, under the user's control. Nothing the worker enforces is actually enforced.
- Cache Storage is not a secure store. Any script running on your origin can read it. It persists after sign-out until your code deletes it, and it ignores
Cache-Control: privateandno-storeunless your code checks for them. Every response you cache extends how long, and to whom, that data stays exposed. - A service worker turns XSS into persistence. The specification explains why workers can't be loaded from other origins: "service workers create the opportunity for a bad actor to turn a bad day into a bad eternity." An attacker only needs one URL on your origin that returns attacker-controlled bytes with a JavaScript MIME type: an uploads directory, a JSONP endpoint with a user-controlled callback, a misconfigured static bucket. Registering it gives them a proxy in front of every page in its scope, and it survives your fix until a worker update or a storage wipe removes it. The path-based max scope doesn't stop this for pages at or below that path, and the spec states that "the path restriction is not considered a hard security boundary, as only origins are."
messagehandlers trust their senders. Any same-origin page, including one running an injected script, canpostMessage()commands such as "clear caches" or "use this token" to the worker.
Fix. Enforce everything on the server, and harden the registration path.
Authorize on the server, on every request. Treat worker logic as a user-experience layer. An Authorization header the worker adds is a convenience, and the API must still validate the token and the permissions behind it.
Refuse to serve anything else as a worker script. Browsers add Service-Worker: script to service worker script requests (Service Workers specification §6.6), so the server can refuse that header on every URL except your worker's:
// Only these paths may ever be fetched as service worker scripts. With a
// module worker, list its static imports too: the spec fetches them through
// the same hook that adds the header.
const SW_SCRIPTS = new Set(["/sw.js"]);
app.use((req, res, next) => {
if (req.get("Service-Worker") === "script" && !SW_SCRIPTS.has(req.path)) {
res.status(403).type("text/plain").send("Not a service worker script");
return;
}
next();
});
Restrict registration with CSP. A page policy with worker-src 'self', or an exact script URL such as worker-src https://example.com/sw.js, limits the script URLs that register() accepts on the pages it covers. It doesn't help when the attacker's script runs on a page without that policy, so it complements the server check rather than replacing it. See Content Security Policy.
Serve user-generated files from a separate origin, such as usercontent.example.net. A worker registered there can't control your pages.
Don't cache what you can't afford to leak. Skip responses marked private or no-store (the isCacheable() gate checks no-store), keep per-user data in caches that you delete on sign-out, and send Clear-Site-Data on the sign-out response (see Misusing Clear-Site-Data). The cookie-driven purge in Advanced Techniques covers sessions that end without an explicit sign-out.
Accept only known commands from known senders:
const COMMANDS = {
SKIP_WAITING: () => self.skipWaiting(),
CLEAR_RUNTIME_CACHE: () => caches.delete("app-runtime"),
};
self.addEventListener("message", (event) => {
// Commands come from window clients only; ignore other workers and ports.
if (!(event.source instanceof WindowClient)) return;
const type = event.data?.type;
// Object.hasOwn: "constructor" or "__proto__" must not resolve to built-ins.
if (typeof type !== "string" || !Object.hasOwn(COMMANDS, type)) return;
event.waitUntil(COMMANDS[type]());
});
A malicious worker outlives the bug that installed it
If you suspect that a foreign worker was registered on your origin, fixing the XSS or upload hole is not enough, and deleting the attacker's file only makes its update checks fail. Serve a kill-switch worker at the attacker's script URL, with a JavaScript MIME type, and add Clear-Site-Data: "storage" to that response. Browsers fetch that URL from the network during update checks, bypassing every worker, so the header takes effect and unregisters every worker of the origin. The Service Worker Security page covers the threat model in depth.
Debugging stale content¶
Symptom. "I deployed an hour ago, and users (or I) still see the old version." Or one user reports data from last week. A normal reload doesn't help. A hard reload, or clearing site data, does.
Cause. A response can come from any of five layers, and each one goes stale in its own way:
| Layer | How it goes stale | How to tell |
|---|---|---|
| The page itself | The document was restored from the back/forward cache, or an SPA tab has been open for days | performance.getEntriesByType("navigation")[0].type is "back_forward", or the build ID in the page is old |
| The worker's code | A new worker is installed but waiting, so the old worker still controls the page | registration.waiting is not null |
| Cache Storage | A cache-first or stale-while-revalidate strategy returns an old entry, or an old cache shadows a new one | The entry appears under Application › Cache storage, and its Date header is old |
| The browser HTTP cache | The worker's own fetch() and cache.addAll() go through the HTTP cache, so a long max-age feeds stale bytes into the worker | An old Date or a large Age header on the stored response |
| A CDN or proxy | Edge TTLs, a missed purge, or one point of presence holding an old copy | Age, Via and vendor cache-status headers, compared across networks |
The waiting worker is the most common cause by far, and it is not a bug. The default lifecycle keeps the old worker in control until every tab in its scope has closed (Lifecycle). A reload doesn't close the tab, so the old worker keeps serving the old shell. The HTTP cache row is the subtle one, described in HTTP Caching & Service Workers.
Fix. Work down the layers in order, and make each layer observable.
flowchart TD
S["User sees old content"] --> C{"Page controlled by a worker?"}
C -->|"no"| H{"Old Date or large Age header?"}
H -->|"yes"| HC["HTTP cache or CDN: fix Cache-Control, purge the CDN"]
H -->|"no"| O["Origin serves the old build: check the deploy"]
C -->|"yes"| W{"registration.waiting set?"}
W -->|"yes"| WU["Old worker still in control: update prompt or next navigation"]
W -->|"no"| CS{"Response from Cache Storage?"}
CS -->|"yes"| ST["Strategy or cleanup bug: check cache names and strategy"]
CS -->|"no"| NF{"Worker fetch answered by the HTTP cache?"}
NF -->|"yes"| NC["Revalidate: no-cache headers or cache mode no-cache"]
NF -->|"no"| H Paste this into the DevTools console of an affected page. It reports which worker controls the page, whether an update is waiting, what the caches hold, and whether the document went through a fetch event:
(async () => {
const container = navigator.serviceWorker;
const registration = container && (await container.getRegistration());
const nav = performance.getEntriesByType("navigation")[0];
console.table({
controller: container?.controller?.scriptURL ?? "none (uncontrolled or hard reload)",
active: registration?.active?.scriptURL ?? "none",
waiting: registration?.waiting ? "YES: a new version is waiting" : "no",
installing: registration?.installing ? registration.installing.state : "no",
navigationType: nav?.type ?? "unknown",
// workerStart > 0: a service worker received the fetch event for the document.
documentWentThroughWorker: nav ? nav.workerStart > 0 : "unknown",
documentTransferSize: nav?.transferSize ?? "unknown",
});
for (const name of await caches.keys()) {
const cache = await caches.open(name);
const requests = await cache.keys();
const first = requests[0] ? await cache.match(requests[0]) : undefined;
console.log(`${name}: ${requests.length} entries; first entry Date: ${first?.headers.get("Date") ?? "n/a"}`);
}
})();
Make the layers visible in your own builds:
- Put a build ID everywhere. Emit it in the HTML (
<meta name="build" content="2026-09-25.1">), insw.js, and in API responses, and log all three. When they disagree, you know which layer is stale. -
Tag responses in debug builds. A header set by the worker shows the source of each response in the Network panel:
sw-debug.js// Debug builds only. Rewrapping copies headers and drops response.url and // response.redirected, so don't ship this to production. export function tagSource(response, source) { if (response.type === "opaque" || response.type === "opaqueredirect") { return response; // opaque responses can't be rebuilt } const headers = new Headers(response.headers); headers.set("X-SW-Source", source); // "precache" | "runtime" | "network" return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } -
Revalidate documents. Serve HTML with
Cache-Control: no-cache, so the worker's network requests for documents always reach your server or get a cheap304. When a strategy must skip the HTTP cache for one request, pass it explicitly, for examplefetch(url, { cache: "no-cache" }). - Use the DevTools switches deliberately. In Chromium's Application › Service workers pane, Update on reload installs a new version on every navigation even when the bytes are identical, and skips waiting. That is handy while editing, but it hides waiting-worker problems, so turn it off before you test the update experience. Bypass for network sends requests past the worker, and the Network panel filter
is:service-worker-interceptedlists requests the worker handled. The request Timing tab shows "ServiceWorker Preparation" (starting the worker) and "Request to ServiceWorker" phases. Application › Storage › Clear site data resets everything for a clean test. Browser DevTools covers Firefox and Safari as well.
Removing a worker without a kill switch¶
Symptom. You removed the service worker from the site months ago, yet some users still get the old offline shell, old JavaScript and old API behavior. Or you added unregister() code and nothing changed for them. Or pages started reloading in a loop right after you shipped a kill switch.
Cause. Each common way of removing a worker fails for a specific reason:
| What you did | What actually happens |
|---|---|
Deleted sw.js from the server | Update checks get a 404, the update fails, and the installed worker keeps running. Chromium logs "A bad HTTP response code (404) was received when fetching the script." |
Removed the register() call from the pages | Nothing unregisters. The installed worker still controls every navigation in its scope |
Added getRegistrations() and unregister() to the page | This only runs in pages that reach the user. A worker that answers navigations from its cache keeps serving the old HTML, which doesn't contain the new code |
| Unregistered, but didn't delete caches | unregister() never touches Cache Storage. The data stays until your code deletes it or the browser evicts the origin |
Deployed the kill switch at a new URL, such as /sw-kill.js | Browsers check only the script URL stored in the registration, so nobody ever fetches the new file |
Deployed the kill switch while pages still call register("/sw.js") | The kill switch unregisters and reloads the page, the page registers /sw.js again, the kill switch installs again, and the cycle repeats |
| Removed the kill switch after a week | Users who come back later still run the original worker, and their update check fails with a 404 again |
Unregistering also doesn't release open pages. Per the spec, unregister() removes the registration from the registration map immediately (the next navigation is uncontrolled, and a new register() creates a fresh registration), but pages that are already controlled keep their worker until they unload.
Fix. Ship three things together, and leave them in place for months:
- A kill-switch worker at the original URL, with a JavaScript MIME type and
Cache-Control: no-cache. Browsers fetch the worker's URL for update checks without going through any service worker, so the kill switch reaches users even while the old worker serves everything else from its cache. - Pages that no longer call
register(), and that clean up whatever is still registered. - Monitoring. Requests for
/sw.jsthat carryService-Worker: scriptcome only from browsers that are installing or updating a worker. When they stop arriving, the old installations are gone.
// Deployed at the SAME URL as the worker being retired.
self.addEventListener("install", () => {
self.skipWaiting(); // replace the old worker without waiting for tabs to close
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Our caches only: other registrations may share this origin.
const names = await caches.keys();
await Promise.all(names.filter((n) => n.startsWith("app-")).map((n) => caches.delete(n)));
await self.registration.unregister();
// Open pages are not reloaded here. They keep working (this worker has no
// fetch listener) and load from the network on their next navigation.
// Forcing client.navigate() would loop if any page still registers /sw.js.
})(),
);
});
// Deliberately no fetch listener: requests from pages this worker still
// controls go straight to the network.
// Replaces the old registration code. It runs in pages served from the
// network, which, once the kill switch is active, is every page.
if ("serviceWorker" in navigator) {
(async () => {
try {
for (const registration of await navigator.serviceWorker.getRegistrations()) {
const script = registration.active ?? registration.waiting ?? registration.installing;
// Leave other teams' workers alone on a shared origin.
if (script && new URL(script.scriptURL).pathname === "/sw.js") {
await registration.unregister();
}
}
for (const name of await caches.keys()) {
if (name.startsWith("app-")) await caches.delete(name);
}
} catch (error) {
console.warn("[sw] cleanup failed", error);
}
})();
}
# A dedicated log for worker script requests: one line per install or update check.
log_format sw_checks '$time_iso8601 $status "$http_service_worker" "$http_user_agent"';
location = /sw.js {
access_log /var/log/nginx/sw-checks.log sw_checks;
add_header Cache-Control "no-cache" always;
default_type text/javascript;
types { }
}
If a broken worker must be neutralized immediately while pages are open, Updating Service Workers shows a variant that navigates controlled windows, and a pass-through variant that keeps the registration and its push subscription. Use the navigating variant only after the pages have stopped registering the worker.
Misusing Clear-Site-Data¶
Symptom. You send Clear-Site-Data on sign-out or during an incident, but the worker and its caches survive. Or every user loses offline drafts and queued requests. Or sign-out takes seconds in Chrome. Chromium explains several of these in the console:
Clear-Site-Data header on 'https://example.com/logout': Not supported for insecure origins.
Clear-Site-Data header on 'https://example.com/logout': The request's credentials mode prohibits modifying cookies and other local data.
Clear-Site-Data header on 'https://example.com/logout': Unrecognized type: storage.
Clear-Site-Data header on 'https://example.com/logout': No recognized types specified.
Clear-Site-Data header on 'https://example.com/logout': Cleared data types: "storage".
Cause. The Clear-Site-Data specification and its implementations attach several conditions to the header:
- Only network responses count. The spec says the header must be "only respected on responses fetched over network, and not those served by a service worker", because a worker could otherwise fabricate responses that wipe any origin's data. A sign-out response that the worker answers from Cache Storage, or constructs itself, clears nothing. The worker script's own update response does count, which is what makes the header usable as a last-resort kill switch.
- Only credentialed requests count. The spec runs the clearing step only when the request's credentials flag is set.
fetch("/logout", { credentials: "omit" })is ignored, and so is a cross-originfetch("https://api.example.com/logout")with the defaultcredentials: "same-origin". - It clears the response's origin, not yours.
storageandcacheapply to the origin of the response URL. A header sent byapi.example.comclearsapi.example.com, notwww.example.com, where the worker and its caches live. Onlycookiesreaches further, to the whole registrable domain. - Values must be quoted strings.
Clear-Site-Data: storageis invalid. Chromium reports "Unrecognized type: storage." and Firefox matches only the quoted literals. - HTTPS only. Plain
http:responses are ignored, except on potentially trustworthy origins such aslocalhost. cacheis not Cache Storage.cacheclears the HTTP cache (plus, depending on the browser, bfcache, prerenders and similar). The directive that unregisters workers and clears Cache Storage isstorage. Chromium maps it to IndexedDB, Local Storage, Cache Storage, file systems, Background Fetch and service worker registrations. Firefox maps it to all quota-managed storage (after removing the origin's service workers) and push subscriptions.- It clears more than you meant.
storagealso deletes IndexedDB: offline drafts, outbox queues, settings.cookiesand"*"sign the user out of every subdomain. In Chromium,"cache"(and therefore"*") is marked partial: MDN notes that it may cause seconds-long hangs, and that some requests may still be served from the cache until the tab reloads. executionContextsdoesn't work. Chromium never shipped it, Firefox removed it in 68, and Safari removed it in 18.3.
Fix. Give sign-out a dedicated endpoint that the worker never answers, send the header from the worker's origin on a credentialed request, and choose the directives deliberately:
// POST /logout on the same origin as the service worker.
app.post("/logout", requireCsrfToken, (req, res, next) => {
req.session.destroy((error) => {
if (error) return next(error);
// Quoted values. "storage" unregisters the worker and deletes Cache Storage
// and IndexedDB; drop it if unsynced offline data must survive sign-out.
res.set("Clear-Site-Data", '"cache", "cookies", "storage"');
res.set("Cache-Control", "no-store");
res.status(204).end();
});
});
// Endpoints whose responses must come from the network untouched.
const NETWORK_ONLY = new Set(["/logout", "/login", "/auth/refresh"]);
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin === self.location.origin && NETWORK_ONLY.has(url.pathname)) {
return; // no respondWith(): the browser fetches it and processes the header
}
// ...other routes
});
export async function signOut(csrfToken) {
const response = await fetch("/logout", {
method: "POST",
credentials: "same-origin", // the default; "omit" would make browsers ignore the header
headers: { "X-CSRF-Token": csrfToken },
});
if (!response.ok) throw new Error(`sign-out failed: ${response.status}`);
// The header does not reset this document's in-memory state. Start fresh.
window.location.replace("/signed-out");
}
With the Static Routing API, you can declare the same network-only routes at install time, so the browser doesn't even start the worker for them.
| Directive | Clears | Chrome / Edge | Firefox | Safari |
|---|---|---|---|---|
"storage" | Service worker registrations, Cache Storage, IndexedDB, Web Storage and other script-accessible storage for the origin | ✅ 61 | ✅ 63 | ✅ 17 |
"cache" | The HTTP cache for the origin, and depending on the browser other caches | ⚠️ 127 | ✅ 138 | ✅ 17 |
"cookies" | Cookies for the whole registrable domain. The spec adds HTTP authentication credentials, which Chromium does not clear | ✅ 61 | ✅ 63 | ✅ 17 |
"*" | All of the above | ⚠️ 117 | ✅ 63 | ✅ 17 |
"executionContexts" | Reloads the origin's documents | ❌ | ❌ (63 to 67 only) | ❌ (17 to 18.2 only) |
"prefetchCache", "prerenderCache" | Speculation-rules prefetches and prerenders | ✅ 138 | ❌ | ❌ |
⚠️ Chromium's "cache" support is partial: it may cause seconds-long hangs, and some requests may still come from the cache until the tab reloads. Firefox supported "cache" from 63 to 93, removed it in 94, and brought it back in 138.
Support data as of September 2026. See MDN's Clear-Site-Data page for live data. Updating Service Workers describes the emergency configuration that sends "storage" on the worker's own URL.
A pre-release checklist¶
Run through this list before the first release of a worker, and again whenever you change hosting, the CDN or the build:
-
curl -sI https://your.site/sw.jsshows200, a JavaScriptContent-Type,Cache-Control: no-cache, noLocationheader and a smallAge. - The worker URL has never changed, and the build writes the version inside the file.
- Two consecutive fetches of
sw.jsreturn identical bytes. - Only
sw.js(and its module imports, if any) can be served withService-Worker: script. - Scopes end in
/, and no page awaitsnavigator.serviceWorker.readywithout a timeout. - The precache holds the app shell only, within a size budget enforced in CI.
- Every
cache.put()goes through one gate that rejects opaque, non-OK,206,Vary: *andno-storeresponses. - Cache names carry a namespace and a version,
activatedeletes only your own old caches, and runtime caches have limits. - Cache keys are normalized for tracking parameters, and
Varyon precached responses has been checked. - Media routes answer
Rangerequests from full cached files. -
respondWith()is called synchronously and always resolves with aResponse. - Navigation preload is either consumed or disabled.
- Precached navigations are stored under their final URLs, without redirects.
- All listeners are registered synchronously at the top level.
-
skipWaiting()runs only on user request (or old assets and precaches are retained), and the page recovers once from failed lazy imports. -
controllerchangereloads are guarded against first installs, other tabs and loops. - No state lives only in globals, and the app works after the worker is stopped.
- Authorization is enforced on the server, and per-user caches are deleted on sign-out.
- A tested kill-switch worker is in the repository, ready to deploy at the worker's URL.
- Sign-out is excluded from the fetch handler and sends a quoted
Clear-Site-Dataheader from the worker's origin.
The Production Checklist extends this list beyond the service worker.
Further reading¶
On this site
- Service Worker Lifecycle
- Updating Service Workers
- Registration & Scope
- Handling Fetch Events
- Advanced Techniques
- Caching Strategies
- HTTP Caching & Service Workers
- Service Worker Security
External references
- Service Workers specification (W3C Editor's Draft)
- Clear Site Data specification
- MDN: Using Service Workers
- MDN:
Clear-Site-Data - web.dev: The service worker lifecycle
- Chrome for Developers: Fresher service workers, by default
- Chrome for Developers: Handling service worker updates (Workbox)
- Chrome for Developers: Serving cached audio and video
- Vite: Load error handling