SW Template
Versioned service worker template with progress tracking and offline-first caching.
Save the code below as swoff/sw-template.js — the following patterns build on it.
Overview
dist/
├── sw-v1.0.0.js ← Versioned SW
└── version.json ← Version manifestBuild the Service Worker
Create swoff/sw-template.js and append each snippet in order. Lines with // [[...]] are placeholders replaced by the build script.
Placeholders & Configuration
let CACHE_NAME = "";
let ASSETS_TO_CACHE = [];
// [[CACHE_NAME]]
// [[ASSETS_LIST]]
const AUTO_SKIP_WAITING = false;
const CACHE_NAME_RUNTIME = "swoff-runtime";
const TAG_DB_NAME = "swoff-cache-tags";
const TAG_STORE_NAME = "tags";// [[CACHE_NAME]] and // [[ASSETS_LIST]] are replaced by the build script (see Build Scripts). // [[AUTO_SKIP_WAITING]] is also generated by the CLI based on the autoActivate config flag. AUTO_SKIP_WAITING — set to true to activate new versions immediately. CACHE_NAME_RUNTIME — a separate cache for API responses that persists across SW updates. TAG_DB_NAME / TAG_STORE_NAME — IndexedDB database for cache tag indexing, used by the invalidation system.
Install — Download with Progress
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
let downloaded = 0;
for (const url of ASSETS_TO_CACHE) {
try {
const request = new Request(url);
await cache.add(request);
downloaded++;
const percent = Math.round(
(downloaded / ASSETS_TO_CACHE.length) * 100,
);
const clients = await self.clients.matchAll({
includeUncontrolled: true,
});
clients.forEach((client) => {
client.postMessage({
type: "SW_PROGRESS",
percent,
downloaded,
total: ASSETS_TO_CACHE.length,
});
});
} catch (err) {
console.error(`Failed to cache ${url}:`, err);
}
}
if (AUTO_SKIP_WAITING) self.skipWaiting();
})(),
);
});Iterates assets one-by-one, reports progress via SW_PROGRESS postMessage after each success. Auto-activates if AUTO_SKIP_WAITING is true.
Activate — Clean Up Old Caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME && key !== CACHE_NAME_RUNTIME)
.map((key) => caches.delete(key)),
),
),
);
});Deletes old version caches. Keeps CACHE_NAME_RUNTIME so cached API data survives SW updates.
Message — Skip Waiting, Cache Invalidation, and Runtime Cache
self.addEventListener("message", (event) => {
if (event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
if (event.data.type === "INVALIDATE_TAG" && event.data.tag) {
event.waitUntil(invalidateByTag(event.data.tag));
}
if (event.data.type === "CLEAR_RUNTIME_CACHE") {
event.waitUntil(
(async () => {
const cache = await caches.open("swoff-runtime");
const keys = await cache.keys();
await Promise.all(keys.map((r) => cache.delete(r)));
})(),
);
}
});Client sends SKIP_WAITING after user approves an update (see Client Registration). Client sends INVALIDATE_TAG after a mutation syncs, to flush stale cached responses with the given tag. CLEAR_RUNTIME_CACHE is sent on logout when auth is enabled, clearing cached API responses.
Fetch — Cache Strategies
Three caching strategies: Cache-First (default), Stale-While-Revalidate, and Network-First.
The strategy is set by the client via the X-SW-Stale header (see API Integration).
Read Detection
function isReadRequest(request) {
const strategy = request.headers.get("X-SW-Cache-Strategy");
if (strategy === "read") return true;
if (strategy === "mutation") return false;
return request.method === "GET" || request.method === "HEAD";
}Stale-While-Revalidate
Serve cached data instantly, refresh in the background. Best for data that changes infrequently but needs to appear instant.
async function staleWhileRevalidate(event, request) {
const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);
const cached = await runtimeCache.match(request);
if (cached) {
event.waitUntil(refreshCache(runtimeCache, request));
return cached;
}
const response = await fetch(request);
if (response.ok) {
await runtimeCache.put(request, response.clone());
const tagsHeader = request.headers.get("X-SW-Cache-Tags");
if (tagsHeader) {
const url = new URL(request.url).href;
const tags = tagsHeader.split(",").map((t) => t.trim());
await cacheTagUrl(url, tags);
}
}
return response;
}
async function refreshCache(cache, request) {
try {
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
const tagsHeader = request.headers.get("X-SW-Cache-Tags");
if (tagsHeader) {
const url = new URL(request.url).href;
const tags = tagsHeader.split(",").map((t) => t.trim());
await cacheTagUrl(url, tags);
}
}
} catch {
// Background refresh failed — stale cache remains usable
}
}Cache-First (Default)
self.addEventListener("fetch", (event) => {
if (!isReadRequest(event.request)) return;
if (event.request.headers.get("X-SW-Stale") === "true") {
event.respondWith(staleWhileRevalidate(event, event.request));
return;
}
event.respondWith(
(async () => {
const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);
const byRequest = await runtimeCache.match(event.request);
if (byRequest) return byRequest;
if (event.request.mode === "navigate") {
const spa = await cache.match("/index.html");
if (spa) return spa;
}
const response = await fetch(event.request);
if (response.ok) {
const cloned = response.clone();
event.waitUntil(
(async () => {
await runtimeCache.put(event.request, cloned);
const tagsHeader = event.request.headers.get("X-SW-Cache-Tags");
if (tagsHeader) {
const url = new URL(event.request.url).href;
const tags = tagsHeader.split(",").map((t) => t.trim());
await cacheTagUrl(url, tags);
}
})(),
);
}
return response;
})(),
);
});isReadRequest checks the X-SW-Cache-Strategy header (set by the client, see API Integration), then falls back to method. Mutations bypass the SW entirely. When X-SW-Stale: true is set, the SW serves cached data immediately and refreshes in the background. Without it, the default cache-first strategy is used: runtime cache by full request, SPA /index.html for navigations, network with cache-on-success. In both modes, the X-SW-Cache-Tags header is stored (see tag management) so selective invalidation works after mutations sync.
The SW does not replace network errors with custom messages. If fetch() rejects (network failure, DNS error, CORS, etc.) and there is no cached fallback for that strategy, the error propagates to the client as a standard TypeError — the same as if no SW were present. This lets client code distinguish real failures from offline states.
Tag Management
These functions manage the tag-to-URL index used by INVALIDATE_TAG:
function openTagDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(TAG_DB_NAME, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(TAG_STORE_NAME)) {
const store = db.createObjectStore(TAG_STORE_NAME, { keyPath: "url" });
store.createIndex("by-tag", "tags", { multiEntry: true });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
}
async function cacheTagUrl(url, tags) {
const db = await openTagDB();
const tx = db.transaction(TAG_STORE_NAME, "readwrite");
const store = tx.objectStore(TAG_STORE_NAME);
store.put({ url, tags });
await new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function invalidateByTag(tag) {
const db = await openTagDB();
const tx = db.transaction(TAG_STORE_NAME, "readonly");
const store = tx.objectStore(TAG_STORE_NAME);
const index = store.index("by-tag");
const entries = await new Promise((resolve, reject) => {
const request = index.getAll(tag);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
await db.close();
const runtimeCache = await caches.open(CACHE_NAME_RUNTIME);
for (const entry of entries) {
await runtimeCache.delete(entry.url);
}
// Remove entries from tag index
const writeDb = await openTagDB();
const writeTx = writeDb.transaction(TAG_STORE_NAME, "readwrite");
const writeStore = writeTx.objectStore(TAG_STORE_NAME);
for (const entry of entries) {
writeStore.delete(entry.url);
}
await new Promise((resolve, reject) => {
writeTx.oncomplete = () => resolve();
writeTx.onerror = () => reject(writeTx.error);
});
// Notify all clients
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({ type: "TAG_INVALIDATED", tag });
});
}Security Considerations
Subresource Integrity
The SW caches all assets at install time via ASSETS_TO_CACHE + cache.addAll(). If your build pipeline or CDN is compromised, the SW will cache and serve malicious content — even offline. Consider adding an integrity check after caching by verifying a hash of each cached response against a known value. Alternatively, pin your asset versions and use lockfiles in your build process.
Runtime Cache Persists Across SW Updates
The swoff-runtime cache (for API responses) is intentionally preserved when the SW updates — this is what keeps cached data available after an update. However, it also means a poisoned API response (e.g., from a supply-chain attack) will survive SW updates. It is only cleared by:
- Calling
invalidateByTag(tag)from client code - The
activateevent cleaning old version caches (runtime cache is exempt) - The user manually clearing site data
To mitigate this, call invalidateByTag() aggressively after mutations, and consider periodically trimming the runtime cache (see Cache Trimming).
Events
| Event | Source | Detail | Purpose |
|---|---|---|---|
sw-version-detected | main.tsx | none | Version info available on window |
sw-update-available | main.tsx | { version: string } | New version on server |
sw-progress | main.tsx (from SW) | { percent: number } | Download progress |
sw-ready | main.tsx | none | SW active and controlling page |
sw-error | main.tsx | none | SW registration failed |
TAG_INVALIDATED | SW (postMessage) | { tag: string } | Cache entries with tag cleared — broadcast to all tabs for cross-tab sync |
CLEAR_RUNTIME_CACHE | Client (postMessage) | none | Clears runtime cache on logout (auth-enabled builds only) |
BACKGROUND_SYNC_COMPLETE | SW (postMessage) | { succeeded, failed, tags } | Background sync result — triggers mutation-sync-complete event on clients |
Storage
| Key | Type | Purpose |
|---|---|---|
localStorage['swRegisteredVersion'] | string | Tracks registered SW version |
sessionStorage['sw-dismissed-update'] | string ("true") | Suppresses update dialog for session |
swoff-cache-tags (IDB) | object store | URL-to-tags index for invalidation |
Next Steps
- Build Scripts — automate versioning
- API Integration — coordinate client requests with the SW
Swoff