Service Workers & Caching Strategies
Service Workers & Caching Strategies The Service Worker (SW) is a JS process the browser runs in parallel with your page. It intercepts every outgoing request, …
Service Workers & Caching Strategies
The Service Worker (SW) is a JS process the browser runs in parallel with your page. It intercepts every outgoing request, lets you cache assets, and works offline. This is the engine behind every PWA.
Lifecycle: register, install, activate
// 1) Register from the page — scope defaults to the directory of sw.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', {scope: '/'})
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW failed:', err))
})
}
// 2) Inside sw.js — install: pre-cache the app shell
const CACHE = 'app-v3'
const PRECACHE_URLS = [
'/',
'/offline.html',
'/styles.css',
'/app.js',
]
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then(c => c.addAll(PRECACHE_URLS))
)
self.skipWaiting() // activate the new SW immediately
})
// 3) Activate: clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
).then(() => self.clients.claim()) // take control of open pages
)
})
// Lifecycle states:
// parsed → installing → installed (waiting) → activating → activated → redundant
// Without skipWaiting(), a new SW waits until all pages using the old one close.Caching Strategies
// Inside sw.js — pick a strategy per request type
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url)
// 1) CACHE FIRST — for hashed static assets that never change
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith(cacheFirst(event.request))
return
}
// 2) NETWORK FIRST — for HTML and JSON that should be fresh
if (event.request.mode === 'navigate' || event.request.destination === 'document') {
event.respondWith(networkFirst(event.request))
return
}
// 3) STALE WHILE REVALIDATE — fast response, refresh in background
if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(event.request))
return
}
})
async function cacheFirst(req) {
const hit = await caches.match(req)
return hit ?? fetch(req).then(res => {
caches.open(CACHE).then(c => c.put(req, res.clone()))
return res
})
}
async function networkFirst(req) {
try {
const res = await fetch(req)
caches.open(CACHE).then(c => c.put(req, res.clone()))
return res
} catch {
return (await caches.match(req)) ?? caches.match('/offline.html')
}
}
async function staleWhileRevalidate(req) {
const cache = await caches.open(CACHE)
const cached = await cache.match(req)
const fetching = fetch(req).then(res => { cache.put(req, res.clone()); return res })
return cached ?? fetching // serve cached if present, fetch in background
}When To Use Each
Strategy Best for Trade-off
─────────────────── ──────────────────────────────── ──────────────────
Cache First Hashed JS/CSS, fonts, icons Stale if cache key
doesn't update
Network First Document/HTML, fresh data Slower than cache
Stale While Reval. API, feed, lists Eventually consistent
Network Only Auth, payments, analytics No offline
Cache Only Pre-bundled shell pieces No update path
Rule of thumb:
- immutable URL (hash in filename) → cache first
- mutable URL → network first or SWR
- never cache: auth endpoints, write APIs, anything with PII
Libraries that hide this for you:
- Workbox (most production PWAs)
- serwist (Workbox successor, Next.js friendly)
- vite-plugin-pwa (Vite/SvelteKit/Nuxt)Updating the App
// New SW found → notify the user and let them choose when to update.
// Page lifecycle:
const reg = await navigator.serviceWorker.register('/sw.js')
reg.addEventListener('updatefound', () => {
const installing = reg.installing
if (!installing) return
installing.addEventListener('statechange', () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
// A new version is waiting to activate
showUpdatePrompt(() => {
installing.postMessage({type: 'SKIP_WAITING'})
})
}
})
})
// Inside sw.js:
self.addEventListener('message', (e) => {
if (e.data?.type === 'SKIP_WAITING') self.skipWaiting()
})
// When the new SW activates, the page should reload to use it:
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload()
})
// The number-one bug: forgetting to bust the cache key on deploy.
// Best practice is to embed a build hash: const CACHE = 'app-' + __BUILD_HASH__.