All topics
Frontend · Learning hub

PWA notes for developers

Master PWA with a curated set of 2 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Frontend notes
PWA

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

Web App Manifest, Install & Offline UX

Web App Manifest, Install & Offline UX The manifest is the metadata the OS uses when installing your site. Combined with a Service Worker and HTTPS, it turns th

Web App Manifest, Install & Offline UX

The manifest is the metadata the OS uses when installing your site. Combined with a Service Worker and HTTPS, it turns the page into a Progressive Web App.

manifest.webmanifest

{
  "name": "DevRecall — Developer Knowledge",
  "short_name": "DevRecall",
  "description": "Organise your tech notes, bookmarks, and study material.",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#0f0f12",
  "theme_color": "#6a5acd",
  "icons": [
    {"src": "/icons/192.png", "sizes": "192x192", "type": "image/png"},
    {"src": "/icons/512.png", "sizes": "512x512", "type": "image/png"},
    {"src": "/icons/maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
  ],
  "shortcuts": [
    {"name": "New page", "url": "/dashboard?new=page", "icons": [{"src": "/icons/new.png", "sizes": "96x96"}]}
  ]
}
<!-- Link it from <head> on every page -->
<link rel="manifest" href="/manifest.webmanifest">

<!-- Browser UI theming -->
<meta name="theme-color" content="#6a5acd">

<!-- iOS-specific -->
<link rel="apple-touch-icon" href="/icons/192.png">
<meta name="apple-mobile-web-app-title" content="DevRecall">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

Installability Criteria

  • Served over HTTPS (or localhost for dev).

  • Has a manifest with name, short_name, start_url, display (standalone or fullscreen), and at least one 192px + 512px icon.

  • Registers a Service Worker with a fetch handler (Android Chrome). iOS does not require a SW for "Add to Home Screen".

  • User has interacted with the site (Chrome heuristic) before showing the prompt.

Custom Install Prompt

// Chrome fires `beforeinstallprompt` when criteria are met.
// Stash the event and trigger it from your UI when the user clicks Install.
let deferred: any = null

window.addEventListener('beforeinstallprompt', (e: Event) => {
  e.preventDefault()         // suppress the default mini-infobar
  deferred = e
  showInstallButton()
})

async function onInstallClick() {
  if (!deferred) return
  deferred.prompt()
  const {outcome} = await deferred.userChoice  // 'accepted' | 'dismissed'
  console.log('install:', outcome)
  deferred = null
  hideInstallButton()
}

window.addEventListener('appinstalled', () => {
  console.log('installed!')
  hideInstallButton()
})

// iOS Safari has no programmatic install — show a one-time hint:
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent)
const isStandalone = (window.navigator as any).standalone === true
if (isIOS && !isStandalone) showAddToHomeScreenHint()

Offline UX

// Detect online status — kept in sync with the OS
window.addEventListener('online',  () => updateBanner('back online'))
window.addEventListener('offline', () => updateBanner('offline mode'))

if (!navigator.onLine) updateBanner('offline mode')

// Three patterns worth knowing:

// 1) Offline fallback page (served from cache when navigation fails)
self.addEventListener('install', (e) => {
  e.waitUntil(caches.open(CACHE).then(c => c.add('/offline.html')))
})

// 2) Background Sync — replay failed POSTs once the user is back online
const reg = await navigator.serviceWorker.ready
await reg.sync.register('replay-queued-actions')
// In sw.js:
self.addEventListener('sync', (e) => {
  if (e.tag === 'replay-queued-actions') {
    e.waitUntil(replayFromIndexedDB())
  }
})

// 3) IndexedDB for app state (small) — Cache API for static assets (large).
//    Don't store gigabytes in Cache; browsers evict aggressively under quota.

Push Notifications (Web Push)

// 1) Ask permission only after the user expresses intent (button click).
const perm = await Notification.requestPermission()
if (perm !== 'granted') return

// 2) Subscribe — needs VAPID keys generated server-side
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,                 // required
  applicationServerKey: urlBase64ToUint8(VAPID_PUBLIC_KEY),
})

// 3) Ship `sub.toJSON()` to your backend; store per user.
await fetch('/api/push/subscribe', {method: 'POST', body: JSON.stringify(sub)})

// 4) Server signs and sends with web-push library; SW handles it:
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {}
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body, icon: '/icons/192.png', data: {url: data.url},
    })
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  event.waitUntil(clients.openWindow(event.notification.data?.url ?? '/'))
})

// iOS supports Web Push only when installed as a PWA (Safari 16.4+).

Keep your PWA knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever