Service Worker Strategies
Advanced service worker caching strategies for PWAs
Service Workers are the backbone of Progressive Web Apps. This guide covers advanced caching strategies used by production PWAs like Twitter, Instagram, and Pinterest.
Service Worker Lifecycle
Install → Activate → Fetch/Message → Update → TerminateBasic Service Worker
// sw.js
self.addEventListener('install', (event) => {
console.log('Service Worker installing');
// Force activation
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('Service Worker activating');
// Claim all clients
event.waitUntil(clients.claim());
});
self.addEventListener('fetch', (event) => {
console.log('Fetching:', event.request.url);
});Registration
// app/register-sw.ts
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.log('Service Workers not supported');
return;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
console.log('Service Worker registered:', registration);
// Listen for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
console.log('New version available!');
showUpdateNotification();
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
}
// Register on load
if (typeof window !== 'undefined') {
window.addEventListener('load', registerServiceWorker);
}Caching Strategies
1. Cache First (Offline First)
Best for: Static assets that rarely change
// sw.js
const CACHE_NAME = 'static-v1';
const STATIC_ASSETS = [
'/',
'/offline.html',
'/styles.css',
'/app.js',
'/logo.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
// Return cached version or fetch from network
return cachedResponse || fetch(event.request);
})
);
});2. Network First (Fresh Content)
Best for: Dynamic content, API calls
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((networkResponse) => {
// Clone and cache the response
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return networkResponse;
})
.catch(() => {
// Network failed, try cache
return caches.match(event.request);
})
);
});3. Stale While Revalidate
Best for: Assets that can be slightly outdated
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
// Return cached version immediately
const fetchPromise = fetch(event.request).then((networkResponse) => {
// Update cache in background
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, networkResponse.clone());
});
return networkResponse;
});
// Return cached version or wait for network
return cachedResponse || fetchPromise;
})
);
});4. Network Only
Best for: Always need fresh data, no caching
self.addEventListener('fetch', (event) => {
// Always fetch from network, never cache
event.respondWith(fetch(event.request));
});5. Cache Only
Best for: Pre-cached assets, no network needed
self.addEventListener('fetch', (event) => {
// Only return from cache, never network
event.respondWith(caches.match(event.request));
});Advanced Patterns
Route-Based Strategies
// Different strategies for different routes
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API calls: Network First
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
}
// Images: Cache First
else if (request.destination === 'image') {
event.respondWith(cacheFirst(request));
}
// HTML: Stale While Revalidate
else if (request.destination === 'document') {
event.respondWith(staleWhileRevalidate(request));
}
// Everything else: Network First
else {
event.respondWith(networkFirst(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const network = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, network.clone());
return network;
}
async function networkFirst(request) {
try {
const network = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, network.clone());
return network;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
// Fallback to offline page
return caches.match('/offline.html');
}
}
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const fetchPromise = fetch(request).then((network) => {
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, network.clone());
});
return network;
});
return cached || fetchPromise;
}Cache Expiration
const CACHE_NAME = 'dynamic-v1';
const MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_ITEMS = 50;
async function cleanupCache() {
const cache = await caches.open(CACHE_NAME);
const keys = await cache.keys();
// Remove old entries
const now = Date.now();
for (const request of keys) {
const response = await cache.match(request);
const dateHeader = response?.headers.get('date');
if (dateHeader) {
const age = now - new Date(dateHeader).getTime();
if (age > MAX_AGE) {
await cache.delete(request);
}
}
}
// Remove excess entries (LRU)
const remainingKeys = await cache.keys();
if (remainingKeys.length > MAX_ITEMS) {
const toDelete = remainingKeys.slice(0, remainingKeys.length - MAX_ITEMS);
await Promise.all(toDelete.map(key => cache.delete(key)));
}
}
self.addEventListener('activate', (event) => {
event.waitUntil(cleanupCache());
});Versioning and Updates
const VERSION = 'v1.2.0';
const CACHE_NAME = `app-${VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
// Delete old caches
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
});Workbox (Google's SW Library)
npm install workbox-webpack-plugin// next.config.js (with Workbox)
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.plugins.push(
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 300, // 5 minutes
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
{
urlPattern: /\.(?:js|css)$/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'static-resources',
},
},
],
})
);
}
return config;
},
};Workbox Strategies
import { registerRoute } from 'workbox-routing';
import {
NetworkFirst,
CacheFirst,
StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// API calls
registerRoute(
/^https:\/\/api\.example\.com\/.*/,
new NetworkFirst({
cacheName: 'api',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60, // 5 minutes
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
})
);
// Images
registerRoute(
/\.(?:png|jpg|jpeg|svg|gif|webp)$/,
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
}),
],
})
);
// Fonts
registerRoute(
/\.(?:woff|woff2|ttf|otf)$/,
new CacheFirst({
cacheName: 'fonts',
plugins: [
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
}),
],
})
);Communication with Service Worker
From App to SW
// Send message to service worker
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'SKIP_WAITING',
});
}
// Listen for response
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SW_UPDATED') {
showUpdateBanner();
}
});From SW to App
// sw.js
self.addEventListener('message', (event) => {
if (event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Notify all clients
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'SW_UPDATED',
});
});
});Testing Service Workers
describe('Service Worker', () => {
it('should register service worker', async () => {
const registration = await navigator.serviceWorker.register('/sw.js');
expect(registration).toBeTruthy();
});
it('should cache static assets', async () => {
const cache = await caches.open('static-v1');
const response = await cache.match('/');
expect(response).toBeTruthy();
});
it('should serve from cache when offline', async () => {
// Simulate offline
await page.setOfflineMode(true);
const response = await page.goto('/');
expect(response.status()).toBe(200);
});
});Best Practices
- Version your caches: Use cache names with versions
- Clean old caches: Delete old versions on activate
- Selective caching: Don't cache everything
- Network timeout: Add timeouts to network requests
- Offline fallback: Always have offline page
- Update notifications: Tell users when update available
- skipWaiting carefully: Can break active pages
- Test offline: Test app works without network
- Monitor errors: Track SW errors in production
- Use Workbox: For production apps
Common Pitfalls
❌ Caching everything: Wastes storage
✅ Selective caching by route/type
❌ No cache versioning: Old content sticks
✅ Version cache names, clean old ones
❌ No offline fallback: Broken experience
✅ Always cache offline page
❌ skipWaiting() immediately: Breaks active tabs
✅ Notify user, let them decide
❌ No update mechanism: Users stuck on old version
✅ Check for updates, notify user
Service Workers are powerful—use the right caching strategy for each resource type!