PWA
01 / 02

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, 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__.

Keep your own version of these notes — editable, searchable, and organised by your stack.

Start free