Service Worker Patterns
Quick Guide: Use Service Workers for offline-first applications with sophisticated caching. Implement cache-first for static assets, network-first for HTML, and stale-while-revalidate for API data. Always handle the install/activate/fetch lifecycle properly and provide user control over updates.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST call event.waitUntil() in install and activate handlers to signal completion)
(You MUST version your caches and clean up old versions during activation)
(You MUST implement proper update detection and give users control over when updates apply)
(You MUST use clients.claim() in activate if you need immediate control of clients)
(You MUST handle all fetch failures with appropriate offline fallbacks)
</critical_requirements>
Auto-detection: Service Worker, serviceWorker, sw.js, sw.ts, navigator.serviceWorker, caches, Cache API, CacheStorage, skipWaiting, clients.claim, precache, offline-first, PWA
When to use:
- •Building Progressive Web Apps (PWAs) with offline support
- •Implementing sophisticated caching strategies beyond browser defaults
- •Providing offline fallback pages or cached content
- •Controlling how network requests are handled and cached
Key patterns covered:
- •Service Worker lifecycle (install, activate, fetch)
- •Caching strategies (cache-first, network-first, stale-while-revalidate)
- •Precaching critical assets during installation
- •Runtime caching for dynamic content
- •Update detection and user-controlled updates
- •Offline fallback pages
When NOT to use:
- •Simple websites without offline requirements
- •When browser HTTP caching is sufficient
- •For real-time data that must always be fresh (use network-only)
Detailed Resources:
- •For code examples, see examples/
- •For decision frameworks and anti-patterns, see reference.md
<philosophy>
Philosophy
Service Workers are programmable network proxies that run in a separate thread, intercepting requests between your application and the network. They enable offline functionality, sophisticated caching, and background operations that weren't previously possible on the web.
The Service Worker lifecycle is designed for safety:
- •
Install Phase: Download and cache critical assets. The worker is "waiting" until installation completes.
- •
Waiting Phase: New workers wait for all tabs using the old worker to close, preventing version conflicts.
- •
Activate Phase: Old caches are cleaned up, and the worker takes control.
- •
Fetch Phase: The active worker intercepts all network requests within its scope.
Core Principles:
- •
Safety First: The lifecycle exists to prevent running multiple versions simultaneously, which could corrupt state or cause inconsistent behavior.
- •
User Control: Users should decide when updates apply, not be surprised by sudden behavior changes mid-session.
- •
Graceful Degradation: Always provide fallbacks when network and cache both fail.
- •
Cache Versioning: Version your caches to enable clean upgrades and prevent unbounded growth.
Lifecycle Diagram:
Registration → Download → Install → Waiting → Activate → Fetch
↓ ↓
(skipWaiting) (claim)
<patterns>
Core Patterns
Pattern 1: Service Worker Registration
Register the service worker from your main application with proper error handling and update detection.
// register-service-worker.ts
const SW_PATH = "/sw.js";
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
interface ServiceWorkerState {
registration: ServiceWorkerRegistration | null;
updateAvailable: boolean;
applyUpdate: () => void;
}
async function registerServiceWorker(): Promise<ServiceWorkerState> {
if (!("serviceWorker" in navigator)) {
console.log("Service Workers not supported");
return {
registration: null,
updateAvailable: false,
applyUpdate: () => {},
};
}
try {
const registration = await navigator.serviceWorker.register(SW_PATH, {
scope: "/",
updateViaCache: "none", // Always check server for updates
});
console.log("Service Worker registered:", registration.scope);
// Check for updates periodically
setInterval(() => {
registration.update();
}, UPDATE_CHECK_INTERVAL_MS);
// Track update availability
let updateAvailable = false;
let waitingWorker: ServiceWorker | null = null;
const handleUpdate = (worker: ServiceWorker) => {
waitingWorker = worker;
updateAvailable = true;
// Notify UI that update is available
window.dispatchEvent(new CustomEvent("sw-update-available"));
};
// Check if already waiting
if (registration.waiting) {
handleUpdate(registration.waiting);
}
// Listen for new installations
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
if (
installing.state === "installed" &&
navigator.serviceWorker.controller
) {
handleUpdate(installing);
}
});
});
// Reload when new worker takes control
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
});
return {
registration,
updateAvailable,
applyUpdate: () => {
if (waitingWorker) {
waitingWorker.postMessage({ type: "SKIP_WAITING" });
}
},
};
} catch (error) {
console.error("Service Worker registration failed:", error);
return {
registration: null,
updateAvailable: false,
applyUpdate: () => {},
};
}
}
export { registerServiceWorker };
export type { ServiceWorkerState };
Why good: Named constant for interval, proper feature detection, periodic update checks, tracks waiting worker for user-controlled updates, handles controller change to reload, named exports
Pattern 2: Basic Service Worker Structure
The service worker file with proper lifecycle handling, cache versioning, and type safety.
Constants
// sw.ts
declare const self: ServiceWorkerGlobalScope;
const CACHE_VERSION = "v1.0.0";
const CACHES = {
static: `static-${CACHE_VERSION}`,
pages: `pages-${CACHE_VERSION}`,
images: `images-${CACHE_VERSION}`,
api: `api-${CACHE_VERSION}`,
} as const;
const PRECACHE_URLS = [
"/",
"/offline.html",
"/manifest.json",
"/styles/app.css",
"/scripts/app.js",
] as const;
const MAX_CACHE_ITEMS = {
images: 100,
api: 50,
} as const;
const NETWORK_TIMEOUT_MS = 3000;
Install Handler
// Install - precache critical assets
self.addEventListener("install", (event: ExtendableEvent) => {
console.log("[SW] Installing version:", CACHE_VERSION);
event.waitUntil(
(async () => {
const cache = await caches.open(CACHES.static);
await cache.addAll(PRECACHE_URLS);
console.log("[SW] Precached", PRECACHE_URLS.length, "assets");
// Do NOT call skipWaiting here - let user control updates
})(),
);
});
Activate Handler
// Activate - cleanup old caches
self.addEventListener("activate", (event: ExtendableEvent) => {
console.log("[SW] Activating version:", CACHE_VERSION);
event.waitUntil(
(async () => {
// Delete old caches
const cacheNames = await caches.keys();
const currentCaches = Object.values(CACHES);
await Promise.all(
cacheNames
.filter((name) => !currentCaches.includes(name))
.map((name) => {
console.log("[SW] Deleting old cache:", name);
return caches.delete(name);
}),
);
// Take control of all clients immediately
await self.clients.claim();
console.log("[SW] Claimed all clients");
})(),
);
});
Message Handler
// Handle messages from clients
self.addEventListener("message", (event: ExtendableMessageEvent) => {
if (event.data?.type === "SKIP_WAITING") {
console.log("[SW] Skip waiting requested");
self.skipWaiting();
}
});
Why good: All constants named and typed, cache versioning for clean upgrades, waitUntil signals completion, old caches cleaned up, message handler for user-controlled skipWaiting
Pattern 3: Cache-First Strategy
Return cached response immediately, fall back to network. Best for static assets that don't change often.
// Cache-first: Check cache, fall back to network
async function cacheFirst(
request: Request,
cacheName: string,
): Promise<Response> {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
console.log("[SW] Cache hit:", request.url);
return cachedResponse;
}
console.log("[SW] Cache miss, fetching:", request.url);
try {
const networkResponse = await fetch(request);
// Only cache successful responses
if (networkResponse.ok) {
// Clone before caching (response can only be consumed once)
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error("[SW] Fetch failed:", request.url, error);
// Return offline fallback for navigation requests
if (request.mode === "navigate") {
const offlinePage = await caches.match("/offline.html");
if (offlinePage) return offlinePage;
}
throw error;
}
}
When to use: Static assets (CSS, JS, images), fonts, versioned resources with cache-busting hashes
Why good: Checks cache first for speed, caches network responses, clones before put, handles failures with offline fallback
Pattern 4: Network-First Strategy
Try network first, fall back to cache if offline. Best for content that should be fresh but work offline.
// Network-first: Try network, fall back to cache
async function networkFirst(
request: Request,
cacheName: string,
timeoutMs: number = NETWORK_TIMEOUT_MS,
): Promise<Response> {
const cache = await caches.open(cacheName);
try {
// Race network request against timeout
const networkResponse = await Promise.race([
fetch(request),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Network timeout")), timeoutMs),
),
]);
// Cache successful responses
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log("[SW] Network failed, trying cache:", request.url);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline page for navigation requests
if (request.mode === "navigate") {
const offlinePage = await caches.match("/offline.html");
if (offlinePage) return offlinePage;
}
throw error;
}
}
When to use: HTML pages, API requests that need fresh data but should work offline, frequently updated content
Why good: Timeout prevents hanging on slow connections, caches successful responses for offline, proper fallback chain
Pattern 5: Stale-While-Revalidate Strategy
Return cached response immediately, update cache in background. Best for content where speed matters but staleness is acceptable.
// Stale-while-revalidate: Return cache, update in background
async function staleWhileRevalidate(
request: Request,
cacheName: string,
): Promise<Response> {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
// Background revalidation (don't await)
const fetchPromise = fetch(request)
.then(async (networkResponse) => {
if (networkResponse.ok) {
await cache.put(request, networkResponse.clone());
console.log("[SW] Cache updated:", request.url);
}
return networkResponse;
})
.catch((error) => {
console.error("[SW] Background fetch failed:", request.url, error);
return null;
});
// Return cached immediately if available, otherwise wait for network
if (cachedResponse) {
console.log("[SW] Returning stale:", request.url);
return cachedResponse;
}
console.log("[SW] No cache, waiting for network:", request.url);
const networkResponse = await fetchPromise;
if (networkResponse) {
return networkResponse;
}
throw new Error("No cached or network response available");
}
When to use: User avatars, non-critical API data, content that should be fast but eventually consistent
Why good: Returns immediately for cached content, background fetch doesn't block, handles both cache-hit and cache-miss scenarios
Pattern 6: Fetch Event Router
Route requests to appropriate caching strategies based on request type and URL.
// Fetch event handler - routes to strategies
self.addEventListener("fetch", (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== "GET") {
return;
}
// Skip cross-origin requests
if (url.origin !== location.origin) {
return;
}
// Route based on request type
if (request.mode === "navigate") {
// HTML pages: Network-first
event.respondWith(networkFirst(request, CACHES.pages));
} else if (request.destination === "image") {
// Images: Cache-first with size limit
event.respondWith(
cacheFirstWithLimit(request, CACHES.images, MAX_CACHE_ITEMS.images),
);
} else if (url.pathname.startsWith("/api/")) {
// API requests: Stale-while-revalidate
event.respondWith(staleWhileRevalidate(request, CACHES.api));
} else {
// Static assets: Cache-first
event.respondWith(cacheFirst(request, CACHES.static));
}
});
Why good: Clear routing logic, skips non-cacheable requests, uses appropriate strategy per content type, named constants for cache names
Pattern 7: Cache Size Management
Limit cache size to prevent unbounded storage growth.
// Limit cache size by removing oldest entries
async function limitCacheSize(cache: Cache, maxItems: number): Promise<void> {
const keys = await cache.keys();
if (keys.length > maxItems) {
// Delete oldest entries (FIFO)
const deleteCount = keys.length - maxItems;
const toDelete = keys.slice(0, deleteCount);
await Promise.all(
toDelete.map((request) => {
console.log("[SW] Evicting from cache:", request.url);
return cache.delete(request);
}),
);
}
}
// Cache-first with size limit
async function cacheFirstWithLimit(
request: Request,
cacheName: string,
maxItems: number,
): Promise<Response> {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
// Limit cache size before adding
await limitCacheSize(cache, maxItems - 1);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error("[SW] Fetch failed:", request.url);
throw error;
}
}
Why good: Prevents storage quota issues, FIFO eviction is predictable, limits checked before adding new entries
Pattern 8: Offline Fallback Page
Create a meaningful offline experience when both cache and network fail.
// offline.html should be precached during install
// Return it for navigation requests that fail
async function handleNavigationFailure(request: Request): Promise<Response> {
// Try the cache first
const cachedPage = await caches.match(request);
if (cachedPage) {
return cachedPage;
}
// Return offline page
const offlinePage = await caches.match("/offline.html");
if (offlinePage) {
return offlinePage;
}
// Last resort: return a basic offline response
return new Response(
`<!DOCTYPE html>
<html>
<head><title>Offline</title></head>
<body>
<h1>You are offline</h1>
<p>Please check your internet connection and try again.</p>
</body>
</html>`,
{
status: 503,
headers: { "Content-Type": "text/html" },
},
);
}
Why good: Graceful degradation chain, always returns something meaningful, inline fallback as last resort
</patterns><integration>
Integration Guide
Service Workers are framework-agnostic. This skill covers the native Service Worker API only. Integration with specific build tools or libraries is handled by their respective documentation.
Works with:
- •Your build tool for precache manifest generation
- •Your web app manifest for PWA installability
- •Your offline page design for user experience
Defers to:
- •Background Sync API (for queuing failed requests)
- •Push API (for push notifications)
- •IndexedDB (for offline data storage beyond cache)
- •Navigation Preload API (for parallel navigation fetching during SW bootup)
Recommended Tools:
- •Workbox - Google's production-ready library for service workers. Provides pre-built caching strategies, precaching with revision management, background sync plugins, and navigation preload support. Use Workbox for production applications instead of hand-rolling all patterns.
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST call event.waitUntil() in install and activate handlers to signal completion)
(You MUST version your caches and clean up old versions during activation)
(You MUST implement proper update detection and give users control over when updates apply)
(You MUST use clients.claim() in activate if you need immediate control of clients)
(You MUST handle all fetch failures with appropriate offline fallbacks)
Failure to follow these rules will result in broken updates, unbounded cache growth, and poor offline experience.
</critical_reminders>