PWA
02 / 02

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 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 own version of these notes — editable, searchable, and organised by your stack.

Start free