webdev.complete
🌟 View Transitions, PWA, Edge
The Modern Frontier
Lesson 116 of 117
25 min

PWAs & Offline

Manifest, service worker, install prompts, background sync.

A Progressive Web App (PWA) is a regular website that ticks a few extra boxes so the browser starts treating it like a native app. It can be installed to the home screen. It can work offline. It can show push notifications. It is the closest thing the open web has to App Store apps, and you only need three ingredients to opt in: a manifest, a service worker, and HTTPS.

The manifest: making your site installable

A web app manifest is a small JSON file that tells the browser how your site should appear when installed. Name it manifest.webmanifest (or manifest.json), serve it from your origin, and link to it from your HTML.

public/manifest.webmanifest
{
  "name": "Wavefront Notes",
  "short_name": "Wavefront",
  "description": "A markdown notebook that works offline.",
  "start_url": "/",
  "scope": "/",
  "display": "standalone",
  "background_color": "#0b0b0e",
  "theme_color": "#7c3aed",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    {
      "src": "/icons/maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Then in your HTML <head>:

html
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#7c3aed" />
<link rel="icon" href="/icons/192.png" />

The fields that actually matter:

  • name / short_name: what shows under the icon. Keep short_name under 12 characters or it gets truncated.
  • start_url: the URL launched when the user taps the icon. Almost always /.
  • display: standalone hides browser chrome (looks like an app). minimal-ui shows minimal browser UI. browser means a regular tab (rare for PWAs).
  • icons: include at least 192px and 512px PNGs. The maskable purpose lets Android safe-zone the icon into its OS shape.
  • theme_color: tints the status bar and tab strip on mobile.
Lighthouse will tell you what is missing
Chrome DevTools has a PWA audit under Lighthouse. Run it. It will tell you exactly which manifest fields and SW hooks you need to unlock the install prompt. Treat its output as a checklist.

The service worker: your offline brain

A service worker is a background JavaScript file that the browser keeps alive separately from your page. It can intercept network requests, serve cached responses, and run code even when the page is closed (for push, sync, etc).

Three lifecycle events you need to know:

  • install: fires once when the worker is first registered (or updated). Best place to pre-cache the app shell.
  • activate: fires once after install, when the worker takes control. Best place to delete old caches from previous versions.
  • fetch: fires on every network request the page makes. You decide: serve from cache, fetch from network, or some hybrid.
public/sw.js
const CACHE = "wavefront-v1";
const SHELL = ["/", "/styles.css", "/app.js", "/offline.html"];

self.addEventListener("install", (e) => {
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
  self.skipWaiting(); // activate immediately on update
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

self.addEventListener("fetch", (e) => {
  const { request } = e;
  // Network-first for HTML pages.
  if (request.mode === "navigate") {
    e.respondWith(
      fetch(request).catch(() => caches.match("/offline.html"))
    );
    return;
  }
  // Cache-first for static assets.
  e.respondWith(
    caches.match(request).then((hit) => hit || fetch(request))
  );
});

Register it from your main JS file:

app.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 register failed:", err));
  });
}
Service workers are HTTPS-only
With one exception: localhost. Everywhere else you need a valid TLS certificate. This is non-negotiable. Service workers can intercept every request your site makes, so the browser refuses to load one over an insecure origin.

Caching strategies (the named patterns)

Three strategies cover 90% of real apps. Memorize the names; you will see them in every PWA doc and library.

  • Cache-first: check the cache; if it hits, return it. Only fall back to network on miss. Great for fonts, images, CSS, JS bundles, anything versioned in the URL.
  • Network-first: try the network; fall back to cache if offline. Great for HTML and API responses where freshness matters.
  • Stale-while-revalidate: return the cached copy immediately, then fetch a fresh one in the background and update the cache. The fastest perceived UX. Great for avatars, feeds, dashboards.

Workbox: a service worker library

Writing service workers from scratch is doable but error-prone. Workboxis Google's official set of helpers. It gives you the named strategies above as one-liners, plus precaching, route matching, expiration, background sync, and offline fallbacks.

sw.js
import { precacheAndRoute } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";

// __WB_MANIFEST is injected at build time with the file list + revisions.
precacheAndRoute(self.__WB_MANIFEST);

// Cache-first for images.
registerRoute(
  ({ request }) => request.destination === "image",
  new CacheFirst({ cacheName: "images" })
);

// Network-first for API.
registerRoute(
  ({ url }) => url.pathname.startsWith("/api/"),
  new NetworkFirst({ cacheName: "api", networkTimeoutSeconds: 3 })
);

// SWR for everything else.
registerRoute(
  ({ request }) => ["style", "script", "font"].includes(request.destination),
  new StaleWhileRevalidate({ cacheName: "static" })
);

The install prompt and beforeinstallprompt

When your manifest and SW are valid, Chrome and Edge fire a beforeinstallprompt event. Save it, then call prompt() later from a user gesture (a button click) to show the install UI yourself.

js
let deferredPrompt = null;
const installBtn = document.getElementById("install");

window.addEventListener("beforeinstallprompt", (e) => {
  e.preventDefault();          // don't show Chrome's mini-infobar
  deferredPrompt = e;           // save for later
  installBtn.hidden = false;    // reveal our custom button
});

installBtn.addEventListener("click", async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log(outcome); // "accepted" | "dismissed"
  deferredPrompt = null;
  installBtn.hidden = true;
});

window.addEventListener("appinstalled", () => {
  console.log("PWA installed!");
});
Safari is the awkward one
Safari does not fire beforeinstallprompt. iOS users install by tapping Share > Add to Home Screen. Detect iOS and show a hint. Other than that, Safari has decent PWA support these days.

Push notifications, briefly

PWAs can show notifications even when the tab is closed. The flow is:

  1. Ask the user for permission with Notification.requestPermission().
  2. Subscribe to push using the Push API, getting a PushSubscription object.
  3. Send that subscription to your server and store it.
  4. From your server, send encrypted push messages via a service like Web Push (using web-push on Node) or a managed service.
  5. The service worker's push event fires and you call self.registration.showNotification().

On iOS, push works only for installed PWAs (since Safari 16.4). On Android and desktop, it works from regular tabs too. Permission UX matters: ask only when there is clear value, not on first load.

Quick quiz

Quiz1 / 3

What three things make a website a Progressive Web App?

Recap

  • A PWA = web app manifest + service worker + HTTPS. That is the whole baseline.
  • The manifest controls the install identity (name, icons, display, start_url).
  • The service worker has three lifecycle events: install, activate, fetch.
  • Three caching strategies: cache-first, network-first, stale-while-revalidate. Workbox names them all.
  • Use beforeinstallprompt to drive a custom install button. Push notifications need permission + a server with VAPID keys.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.