Interactive guide to caching strategies, lifecycle, and update patterns
Check the cache first. If a cached response exists, serve it immediately. Only go to the network if the resource isn't in the cache. This is ideal for assets that rarely change — fonts, images, CSS/JS with versioned URLs.
// Cache First: prioritize cache, fallback to network self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((cached) => { if (cached) { return cached; // Cache hit — serve immediately } // Cache miss — fetch from network and cache the response return fetch(event.request).then((response) => { // Only cache valid responses if (!response || response.status !== 200 || response.type !== 'basic') { return response; } const responseToCache = response.clone(); caches.open('cache-first-v1').then((cache) => { cache.put(event.request, responseToCache); }); return response; }); }) ); });
Always try the network first. If the network responds, cache the result and serve it. If the network fails (offline, timeout), fall back to the cached version. Best for content that needs to be up-to-date — API responses, news feeds, user profiles.
// Network First: prioritize fresh data, fallback to cache self.addEventListener('fetch', (event) => { event.respondWith( fetch(event.request) .then((response) => { // Network success — cache and serve if (response && response.status === 200) { const responseToCache = response.clone(); caches.open('network-first-v1').then((cache) => { cache.put(event.request, responseToCache); }); } return response; }) .catch(() => { // Network failed — try cache return caches.match(event.request); }) ); }); // With timeout: race network against a timer function networkFirstWithTimeout(request, timeout = 3000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { caches.match(request).then(resolve); }, timeout); fetch(request).then((response) => { clearTimeout(timer); const clone = response.clone(); caches.open('network-first-v1').then((c) => c.put(request, clone)); resolve(response); }).catch(() => { clearTimeout(timer); caches.match(request).then(resolve); }); }); }
Serve the cached version immediately (fast!), but simultaneously fetch the latest version from the network and update the cache in the background. The user sees stale content now but gets fresh content on the next visit. Great for content that updates but isn't time-critical.
// Stale-While-Revalidate: fast response + background update self.addEventListener('fetch', (event) => { event.respondWith( caches.open('swr-v1').then((cache) => { return cache.match(event.request).then((cached) => { // Always fetch in background to update cache const fetchPromise = fetch(event.request).then((response) => { if (response && response.status === 200) { cache.put(event.request, response.clone()); } return response; }); // Return cache immediately, or wait for network if no cache return cached || fetchPromise; }); }) ); }); // With max-age: only revalidate if cache is older than threshold function swrWithMaxAge(request, maxAgeMs = 60000) { return caches.open('swr-v1').then((cache) => { return cache.match(request).then((cached) => { const cachedDate = cached?.headers.get('sw-cache-date'); const isStale = !cachedDate || (Date.now() - new Date(cachedDate).getTime()) > maxAgeMs; if (isStale) { // Background revalidate fetch(request).then((resp) => { const headers = new Headers(resp.headers); headers.set('sw-cache-date', new Date().toISOString()); return resp.blob().then((body) => { cache.put(request, new Response(body, { headers })); }); }); } return cached || fetch(request); }); }); }
Only serve from the cache. Never go to the network for this request. The resources must have been pre-cached during the install phase. This is the simplest strategy and guarantees offline availability for known assets.
Network is never contacted. Resources must be pre-cached.
const CACHE_NAME = 'cache-only-v1'; const PRE_CACHE_URLS = [ '/', '/index.html', '/styles.css', '/app.js', '/offline.html', ]; // Pre-cache resources during install self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { return cache.addAll(PRE_CACHE_URLS); }) ); }); // Cache Only: serve exclusively from cache self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((cached) => { if (cached) { return cached; } // No fallback — return a 503 response return new Response('Resource not available offline', { status: 503, statusText: 'Service Unavailable', }); }) ); });
Always go to the network. Never use cache. This is equivalent to having no Service Worker for this request, but you can still add logging, analytics, or request modification. Use for requests that must always be live — login, checkout, real-time data.
Cache is bypassed entirely. Fails offline.
// Network Only: always fetch from network self.addEventListener('fetch', (event) => { // Option 1: explicit network only event.respondWith(fetch(event.request)); }); // Option 2: selective — only intercept certain paths self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Skip caching for API and auth requests if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/')) { event.respondWith(fetch(event.request)); return; } // Use a different strategy for other requests... event.respondWith( caches.match(event.request).then((c) => c || fetch(event.request)) ); }); // Option 3: network only with logging self.addEventListener('fetch', (event) => { event.respondWith( fetch(event.request).then((response) => { console.log(`[SW] Fetched: ${event.request.url} (${response.status})`); return response; }) ); });
A Service Worker goes through a specific lifecycle: registration → installation → waiting → activation. Understanding this lifecycle is crucial for handling updates correctly and avoiding unexpected behavior.
The browser downloads and parses the SW script. Registration triggers the install event.
The install event fires. Pre-cache resources here. If any resource fails to cache, installation fails and the SW is discarded.
If another SW is already active, the new one waits here. It won't activate until all pages controlled by the old SW are closed. Use skipWaiting() to bypass.
The activate event fires. Clean up old caches here. After activation, the SW controls all pages in its scope.
The SW now intercepts fetch, push, and sync events. It stays active until replaced by a new version or unregistered.
// Register a service worker if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { try { const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/', }); // Track lifecycle state if (reg.installing) { console.log('Service Worker: installing'); } else if (reg.waiting) { console.log('Service Worker: waiting (update available)'); } else if (reg.active) { console.log('Service Worker: active'); } // Listen for state changes reg.addEventListener('updatefound', () => { const newWorker = reg.installing; newWorker.addEventListener('statechange', () => { console.log(`SW state: ${newWorker.state}`); }); }); } catch (err) { console.error('SW registration failed:', err); } }); }
const CACHE = 'app-v1'; const PRECACHE = ['/', '/index.html', '/app.css', '/app.js']; // Install: pre-cache critical resources self.addEventListener('install', (event) => { console.log('[SW] Installing...'); event.waitUntil( caches.open(CACHE) .then((cache) => cache.addAll(PRECACHE)) .then(() => console.log('[SW] Pre-cached resources')) ); }); // Activate: clean up old caches self.addEventListener('activate', (event) => { console.log('[SW] Activating...'); event.waitUntil( caches.keys().then((names) => { return Promise.all( names .filter((name) => name !== CACHE) .map((name) => { console.log(`[SW] Deleting old cache: ${name}`); return caches.delete(name); }) ); }) ); });
When you deploy a new version of your Service Worker, the browser detects the byte-level difference and starts an update. By default, the new SW waits until all tabs are closed. skipWaiting() forces immediate activation — powerful but needs careful handling.
// Force immediate activation (use with caution) self.addEventListener('install', (event) => { // Skip the waiting phase entirely self.skipWaiting(); event.waitUntil( caches.open('app-v2').then((cache) => { return cache.addAll(['/', '/index.html', '/app.css', '/app.js']); }) ); }); self.addEventListener('activate', (event) => { // Take control of all pages immediately // Without this, existing pages stay on the old SW event.waitUntil(clients.claim()); // Clean up old caches event.waitUntil( caches.keys().then((names) => Promise.all( names .filter((n) => n !== 'app-v2') .map((n) => caches.delete(n)) ) ) ); });
// Detect update and prompt user to reload navigator.serviceWorker.register('/sw.js').then((reg) => { reg.addEventListener('updatefound', () => { const newWorker = reg.installing; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // New SW installed but old one still active if (confirm('New version available! Reload?')) { newWorker.postMessage({ type: 'SKIP_WAITING' }); } } }); }); }); // Reload when the new SW takes over let refreshing = false; navigator.serviceWorker.addEventListener('controllerchange', () => { if (!refreshing) { refreshing = true; window.location.reload(); } }); // In the SW: listen for SKIP_WAITING message // self.addEventListener('message', (event) => { // if (event.data?.type === 'SKIP_WAITING') { // self.skipWaiting(); // } // });