Skip to content

PWA Core Building Blocks

A Progressive Web App is not a separate technology but a combination of web platform building blocks: a secure context that unlocks powerful APIs, a web app manifest that tells the operating system how to present the app, a service worker that sits between the app and the network, a storage layer that keeps the app and its data on the device, a rendering strategy that gets pixels on screen quickly, and an optional capability layer for push, sync, sharing, file handling and badging. Each block works on its own, but the PWA experience — instant repeat loads, offline use, installation, OS integration — only emerges when they are wired together correctly. This page explains each block at the mechanism level, shows exactly how requests flow through them in four real scenarios, and ends with a complete minimal implementation and a list of what production adds.

Key takeaways

  • HTTPS (or localhost) is not optional: service workers, Cache Storage, push, the Storage API and most device APIs are only exposed in a secure context, and the check applies to every ancestor frame.
  • The manifest is metadata for the browser and OS (identity, icons, display mode, scope); it has no runtime behavior of its own and is fetched only by top-level documents, with CORS and the credentials mode of the link's crossorigin attribute.
  • The service worker is an event-driven proxy with no DOM, started on demand and terminated when idle. It controls pages by scope and is updated by a byte-for-byte comparison of its script.
  • Use Cache Storage for HTTP responses, IndexedDB for structured data, OPFS for files and databases; never use localStorage for app data. Quotas are large but storage is best-effort unless persistence is granted.
  • Draw something immediately: an app shell, server-rendered HTML with navigation preload, or streamed responses — then layer capabilities on with feature detection.
  • A correct minimal PWA is five files plus icons and a stylesheet; production adds generated precache manifests, update UX, runtime caching with expiration, data sync, a kill switch, monitoring and tests.

The PWA architecture at a glance

The diagram shows how the blocks relate. The page and the service worker are separate JavaScript contexts that share the origin's storage; the browser consults the manifest for installation and OS integration; external services (push service, OS share sheet, file system) reach the app through events delivered to either the page or the worker.

flowchart LR
    subgraph Device["User's device"]
        subgraph Browser["Browser engine (secure context)"]
            Page["Page: DOM, app shell, UI code"]
            SW["Service worker: fetch, push, sync events"]
            subgraph Storage["Origin storage (quota-managed)"]
                CS["Cache Storage"]
                IDB["IndexedDB"]
                OPFS["OPFS"]
            end
            Manifest["Web app manifest"]
        end
        OS["Operating system: launcher, share sheet, notifications, files"]
    end
    Server["Origin server and CDN"]
    Push["Push service"]

    Page -- "navigations and fetches" --> SW
    SW -- "network fallback" --> Server
    SW <--> CS
    SW <--> IDB
    Page <--> IDB
    Page <--> OPFS
    Page -- "postMessage" --> SW
    Manifest -- "install metadata" --> OS
    OS -- "launch, share, file open" --> Page
    Push -- "push event" --> SW
    SW -- "showNotification" --> OS
Layer Technology Required for a PWA? What it gives you Deep dive
Transport security HTTPS, HSTS, secure contexts Yes Access to every other layer Security & Privacy
Identity and presentation Web app manifest Yes, for installation Name, icons, display mode, scope, launch URL Web App Manifest
Network control Service worker Yes, for offline and reliability Programmable proxy, background events Service Workers
Persistence Cache Storage, IndexedDB, OPFS Yes, for offline Local copies of code, responses and data Caching & Offline
Rendering App shell, SSR, streaming Yes, for perceived performance Fast first paint on repeat visits App Shell Model
Capabilities Push, Badging, Sync, Share, File Handling No, progressive enhancement Engagement and OS integration Device & OS Integration

Layer 1: Secure contexts

What makes a context secure

A secure context is a Window or Worker whose origin is potentially trustworthy and whose ancestors are all secure contexts too. An origin is potentially trustworthy when it has:

  • the scheme https or wss (or file, which browsers treat inconsistently for powerful features);
  • a host in 127.0.0.0/8 or ::1/128;
  • the host localhost or localhost., or any host ending in .localhost or .localhost.;
  • a scheme the browser itself considers authenticated, such as extension schemes.

The ancestor rule is what catches embedded apps: an https://app.example document inside an http://portal.example iframe parent is not a secure context, so every gated API is missing inside that frame (MDN: Secure contexts). You check the result with window.isSecureContext in pages and self.isSecureContext in workers.

Gated APIs are not merely "blocked" in an insecure context — most are not defined at all, because their IDL is annotated [SecureContext]. navigator.serviceWorker is undefined on http://example.com, which is why feature detection code such as "serviceWorker" in navigator quietly returns false instead of throwing.

Local development: the localhost exception

Because localhost and loopback addresses are potentially trustworthy, http://localhost:5173 gets every secure-context API without a certificate. Problems start when you test on another device:

Situation Working approach
Android phone over USB adb reverse tcp:5173 tcp:5173 maps the phone's localhost:5173 to your machine, so the phone loads http://localhost:5173 and keeps the secure-context exemption.
Any device on the LAN Serve HTTPS with a locally trusted certificate (for example a development CA created with mkcert) and install the CA's root certificate on the test device.
Quick experiments in Chromium chrome://flags/#unsafely-treat-insecure-origin-as-secure (or the --unsafely-treat-insecure-origin-as-secure command-line switch) treats listed http:// origins as secure. Developer-only; never ask users to do this.
Quick experiments in Firefox The dom.securecontext.allowlist preference in about:config lists hosts to treat as secure.
iOS devices No override exists; use HTTPS with a trusted certificate or a tunnel that terminates TLS.

Remember that a hostname such as myapp.test or a LAN IP like http://192.168.1.20 is not covered by the exemption.

APIs gated on a secure context

The following table lists the APIs a PWA typically touches, with the additional gates that apply on top of the secure-context requirement. It is not exhaustive; MDN maintains the full list of features restricted to secure contexts.

API Additional requirements Available in service worker?
Service workers (navigator.serviceWorker) Same-origin script with a JavaScript MIME type n/a
Cache Storage (caches) — Yes
Push API Active service worker; permission (user gesture required in Safari and Firefox) Yes (push events)
Notifications Permission; iOS: Home Screen web app only showNotification() yes
Storage API (estimate(), persist(), getDirectory() for OPFS) — Yes (except persist())
Background Sync, Periodic Sync, Background Fetch Service worker; Chromium only Yes
Badging API Installed app on most platforms Yes
Web Share Transient user activation No
Async Clipboard Focus and/or user activation; permission model differs per browser No
Geolocation, getUserMedia() Permission No
Web Authentication (passkeys) User activation for most flows No
Payment Request User activation for show() No
Web Bluetooth, WebUSB, WebHID, Web Serial, Web NFC User activation to request a device; Chromium only, except Web Serial in Firefox 151 on desktop (gated behind a site-permission add-on) Limited
Screen Wake Lock Visible document No
crypto.subtle, crypto.randomUUID() — Yes
Cookie Store API — Yes
registerProtocolHandler() — No

Installation is gated too: every browser that offers installation requires the page to be served over HTTPS (or from localhost). See Installability Criteria.

Mixed content and HSTS

A secure page may not load active content (scripts, iframes, fetch()) over plain HTTP; browsers block it as mixed content, and that includes requests your service worker makes. Chromium also auto-upgrades mixed images, audio and video to HTTPS. Serve everything over HTTPS, send Strict-Transport-Security so returning users never make an initial plain-HTTP request, and consider HSTS preloading for the domain. The HTTP Caching & Service Workers page covers the header set a PWA origin should send.

A defensive bootstrap makes the failure mode visible instead of silent:

src/bootstrap-security.js
export function assertSecureContext() {
  if (globalThis.isSecureContext) return true;

  // Everything that makes this a PWA is undefined here, so fail loudly in
  // development and degrade quietly in production.
  const message =
    `Not a secure context (${location.origin}). Service workers, Cache Storage, ` +
    "push and the Storage API are disabled. Serve over HTTPS or from localhost.";
  if (location.hostname.endsWith(".localhost") || location.hostname === "localhost") {
    console.error(message); // should not happen: localhost is trustworthy
  } else {
    console.warn(message);
  }
  return false;
}

Layer 2: The web app manifest

What the manifest does and does not do

The web app manifest is a JSON file that describes the application to the browser and operating system: its identity (id, name, short_name), launch behavior (start_url, scope, display), appearance (icons, theme_color, background_color) and integration points (shortcuts, share_target, file_handlers, protocol_handlers and more). It is a W3C Working Draft that is continuously updated, with experimental members incubated separately (W3C: Web Application Manifest).

The manifest has no runtime behavior in the page. It does not cache anything, it does not register a service worker, and changing theme_color in the manifest does not recolor an open tab (the <meta name="theme-color"> tag does that). It is read when the browser evaluates installability, when the user installs, and when an installed app launches or checks for updates.

The HTML Standard defines the processing of <link rel="manifest"> precisely, and several details regularly surprise people:

  • Only top-level documents use it. The fetch setup steps return early when the link's document is not in a top-level traversable, so a manifest link inside an iframe is ignored.
  • Only the first <link> in tree order whose rel contains manifest is used.
  • The request's destination is manifest, its mode is cors, and its credentials mode comes from the element's crossorigin attribute: absent or anonymous means same-origin (cookies are sent to your own origin but not to a CDN on another origin), while use-credentials means include. A manifest on a different origin must therefore be served with Access-Control-Allow-Origin, and a manifest that sits behind cookie authentication on another origin needs crossorigin="use-credentials".
  • The response must have a JSON MIME type; the registered type is application/manifest+json with the .webmanifest extension, though any JSON type is accepted.
  • The fetch does not delay the load event. For a site that is not installed, the browser fetches the manifest when it deems it necessary (for example to evaluate installability); for an installed app, whenever the link is inserted or its href changes.

Processing then applies rules that silently drop invalid values. The two that bite most often: a start_url that is not same-origin with the document is ignored (the document URL is used instead), and a scope that does not contain the start_url is ignored (the default scope — the start_url with its last path segment removed — is used instead).

A minimal, correct manifest

manifest.webmanifest
{
  "id": "/",
  "name": "Field Notes",
  "short_name": "Notes",
  "description": "Offline-first notes for field work.",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#0b57d0",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    {
      "src": "/icons/icon-maskable-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

Why each member is there:

Member Why it matters
id The app's identity. If omitted it defaults to start_url — including the ?source=pwa query here — so changing start_url later would create a "different" app. Set it once and never change it. See App Identity & Updates.
name, short_name Install dialogs and app lists use name; launchers with little space use short_name.
start_url Where the installed app opens. The query string lets analytics distinguish installed launches.
scope Which URLs belong to the app. Navigations outside it show browser UI (or open in the browser).
display standalone removes browser UI. See Display Modes.
background_color, theme_color Splash screen and title-bar colors before your CSS loads. See Splash Screens & Theming.
icons Chromium's installability floor is one purpose: any icon of at least 144 px (192 px and 512 px are recommended); a maskable icon avoids white borders on Android. See Icons & Maskable Icons.

The document links it together with the tags browsers still read from HTML:

index.html (head excerpt)
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b57d0" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1f33" media="(prefers-color-scheme: dark)">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/favicon.svg" type="image/svg+xml">
<!-- Safari prefers apple-touch-icon over manifest icons for the Home Screen -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">

Every member, with per-browser support, is documented in the Members Reference and the Manifest Cheat Sheet.

Layer 3: The service worker

An event-driven, programmable network proxy

A service worker is a JavaScript worker registered for an origin and a URL scope. Once active, it receives a fetch event for every navigation inside its scope and for every subresource request made by pages it controls, and it decides how to answer: from Cache Storage, from the network, from a synthesized Response, or any combination. It also receives non-fetch "functional" events — push, notificationclick, sync, periodicsync, backgroundfetchsuccess and others — even when no page is open.

Constraints that follow from this design:

  • No DOM and no synchronous storage. document, window and localStorage do not exist; caches, indexedDB, fetch, clients and registration do. Communicate with pages through postMessage (see Messaging & the Clients API).
  • No persistent state in memory. The browser starts the worker for an event and terminates it when idle, so global variables are lost between events. Persist anything that matters.
  • Lifetimes are bounded. event.waitUntil(promise) and event.respondWith(promise) keep the worker alive until the promise settles, within browser-imposed limits.
  • It is not in the critical path by accident. A worker with a fetch listener adds startup latency to every controlled navigation unless you use Navigation Preload or the Static Routing API (Chromium 123+, Safari 27).

Registration rules and scope

navigator.serviceWorker.register(scriptURL, { scope, type, updateViaCache }) enforces:

  1. The page must be a secure context and the script URL must be same-origin with the page.
  2. The script must be served with a JavaScript MIME type; anything else rejects with a SecurityError. Redirects are not followed for the worker script.
  3. The default scope is the script's directory. The maximum scope is also the script's directory unless the response carries a Service-Worker-Allowed header naming a broader path. Keep sw.js at the site root to control the whole origin.
  4. type: "module" enables ES module syntax (import) in the worker; module service workers are supported in Chromium 91+, Safari 15+ and Firefox 147+.
  5. updateViaCache controls whether the HTTP cache may be used during update checks: "imports" (the default — the main script always bypasses the HTTP cache, imported scripts may use it), "all" or "none".

A page is controlled by at most one registration — the one whose scope is the longest prefix match of the page URL — and keeps that controller for its whole lifetime. The first page load that registers a worker is not controlled unless the worker calls clients.claim() during activation, and a force-reload (Shift + reload) deliberately bypasses the service worker, leaving navigator.serviceWorker.controller set to null. Full rules: Registration & Scope.

Deep dive: scope matching is a string prefix, not a path segment match

When a navigation starts, the browser runs the Match Service Worker Registration algorithm: it serializes the request URL and picks the registration whose serialized scope URL is the longest string prefix of it. Because this is a plain string comparison, a registration with scope /app also controls /application and /app-store/pricing. Always end directory scopes with a slash (/app/) unless you really mean the prefix match. The same rule explains why a worker registered at the root with scope / controls everything on the origin, and why registering a second worker for /admin/ takes over only that subtree: its scope is the longer match. A registration only controls a page if it has an active worker at the time the navigation starts — a worker still installing does not intercept the request.

The lifecycle in one diagram

stateDiagram-v2
    [*] --> parsed: register called
    parsed --> installing: install event dispatched
    installing --> installed: install waitUntil resolves
    installing --> redundant: install fails
    installed --> activating: old worker has no clients, or skipWaiting called
    activating --> activated: activate waitUntil settles
    activated --> redundant: replaced by a newer worker or unregistered

The waiting phase (the installed state while an older worker still controls pages) exists so that a page is never served by two different versions of your code mid-session. Most production bugs involving "users stuck on an old version" or "the page broke after deploy" come from misunderstanding it. The full algorithm, including what skipWaiting() and clients.claim() really do, is in Lifecycle.

How update checks work

The browser checks for a new worker when a controlled page navigates, when a functional event such as push or sync fires and no check happened in the last 24 hours, when you call registration.update(), and when register() is called with a different script URL. It fetches the script (bypassing the HTTP cache according to updateViaCache, and always if the last check was more than 24 hours ago) and compares it byte for byte — together with scripts it imports via importScripts() or static import — to the current version. Any difference starts a new install. See Updating Service Workers for the complete algorithm and a comparison of update UX patterns.

A production-grade registration handles updates explicitly:

src/register-sw.js
/**
 * Registers the service worker and exposes a callback when an update is
 * ready. The page decides when to switch (e.g. after the user clicks
 * "Reload"), so we never swap code under an active form or editor.
 */
export async function registerServiceWorker({ onUpdateReady } = {}) {
  if (!("serviceWorker" in navigator)) return null; // insecure context or unsupported

  let registration;
  try {
    registration = await navigator.serviceWorker.register("/sw.js", {
      scope: "/",
      updateViaCache: "none", // also bypass the HTTP cache for imported scripts
    });
  } catch (err) {
    console.error("Service worker registration failed", err);
    return null;
  }

  let updateAccepted = false;
  const notifyIfWaiting = (worker) => {
    // Only an *update* if a controller already exists; on the very first
    // install there is nothing to replace.
    if (worker && navigator.serviceWorker.controller) {
      onUpdateReady?.(() => {
        updateAccepted = true;
        worker.postMessage({ type: "SKIP_WAITING" });
      });
    }
  };

  // An update may already be waiting from a previous visit.
  notifyIfWaiting(registration.waiting);

  registration.addEventListener("updatefound", () => {
    const installing = registration.installing;
    installing?.addEventListener("statechange", () => {
      if (installing.state === "installed") notifyIfWaiting(installing);
    });
  });

  // When the new worker takes control, reload once so HTML, JS and caches match.
  // clients.claim() on the very first install also fires controllerchange, so
  // only reload when this tab asked for the update. (Other open tabs need a
  // BroadcastChannel notice; see the Updating Service Workers page.)
  let reloading = false;
  navigator.serviceWorker.addEventListener("controllerchange", () => {
    if (!updateAccepted || reloading) return;
    reloading = true;
    location.reload();
  });

  // Long-lived SPA sessions never navigate, so check when the tab regains focus.
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") {
      registration.update().catch(() => {}); // offline: try again next time
    }
  });

  return registration;
}

Layer 4: The storage layer

All storage below is scoped to the origin (and, in third-party contexts, partitioned by top-level site — see Privacy & Storage Partitioning), counts against one quota, and is evicted as a unit: when a browser evicts an origin, it deletes its Cache Storage, IndexedDB, OPFS and service worker registrations together.

Cache Storage: HTTP responses under your control

Cache Storage (caches) stores Request → Response pairs in named caches. It is available to pages and workers, and it is not the HTTP cache: entries never expire on their own, Cache-Control headers are ignored for lookups, and nothing is evicted individually — you add and delete entries explicitly. Behaviors worth knowing:

  • cache.match(request, { ignoreSearch, ignoreMethod, ignoreVary }) matches by URL and, unless ignoreVary is set, honors the stored response's Vary header against the new request's headers.
  • cache.addAll(requests) is atomic: if any response is not an OK status (200–299) or any fetch fails, nothing is stored and the promise rejects. That is the property precaching relies on.
  • cache.put() rejects 206 Partial Content responses and responses with Vary: *.
  • Opaque responses (cross-origin no-cors fetches) can be stored, but their status is hidden (always 0), so you cannot tell an error page from a success. Chromium also pads their quota cost with a pseudo-random amount between 0 and about 14 MiB per opaque response, about 7 MiB on average (Chrome for Developers: caching resources during runtime).

Details and patterns: Cache Storage API, Caching Strategies, Precaching & Runtime Caching.

IndexedDB: structured application data

IndexedDB is an asynchronous, transactional object store with indexes, available in pages and all workers, storing anything the structured clone algorithm supports (objects, Blob, ArrayBuffer, Date, Map). It is the right home for application state, the outbox of unsent writes in an offline-first app, and metadata about cached content. Its raw API is event-based and verbose; most apps use a small promise wrapper such as the idb library. Schema changes happen only in upgradeneeded during a version bump, and an open connection in another tab can block the upgrade — handle the blocked and versionchange events. See IndexedDB.

Origin Private File System: files and databases

OPFS (navigator.storage.getDirectory()) is a private, sandboxed file system per origin, invisible to the user's real file system. Its killer feature is FileSystemSyncAccessHandle, available only in dedicated workers, which gives synchronous, in-place reads and writes — fast enough to run SQLite compiled to WebAssembly. getDirectory() and sync access handles are available in Chromium 86+/102+, Firefox 111+ and Safari 15.2+; createWritable() on file handles arrived in Safari 26. See Origin Private File System.

What not to use for app data

localStorage and sessionStorage are synchronous (they block the main thread), store only strings, are limited to about 5 MiB per origin, throw QuotaExceededError when full, and are not available in service workers — so the worker can never read what the page stored there. Cookies travel with every request and are for server sessions, not data.

Quotas, eviction and persistence

Engine Per-origin quota Default mode
Chromium Up to 60% of total disk Best-effort; persistent if persist() is granted
Firefox Best-effort: smaller of 10% of disk or 10 GiB per site; persistent: 50% of disk up to 8 TiB Best-effort; persist() shows a prompt
Safari / WebKit (iOS 17+, macOS 14+) About 60% of disk in browsers and Home Screen/Dock web apps; about 15% in apps embedding WKWebView Best-effort; persist() granted heuristically

Sources: MDN: Storage quotas and eviction criteria, WebKit: Updates to Storage Policy.

Under storage pressure, browsers evict best-effort origins in least-recently-used order. Safari additionally deletes script-writable storage for sites without user interaction in the last seven days of browser use when cross-site tracking prevention is on, while Home Screen web apps keep their own usage counter. Chromium also offers the Storage Buckets API (Chromium 122+) to split an origin's data into buckets with independent persistence and eviction. Full treatment: Storage Quotas & Persistence.

Choosing a store

Data Store Why
App shell, JS, CSS, fonts, icons Cache Storage (precache) Served directly as Response objects by the service worker
API responses you want to replay offline Cache Storage (runtime cache) or IndexedDB Cache Storage if you serve them as responses; IndexedDB if the UI reads them as data
User documents, drafts, outbox queue IndexedDB Transactions, indexes, access from page and worker
Large binary files, SQLite databases OPFS Streaming and synchronous access in workers
UI preferences read synchronously at startup localStorage (small values only) Acceptable only for tiny, non-critical values
Session identity HTTP-only cookies Sent to the server automatically; not readable by scripts

Layer 5: The app shell and rendering strategy

A service worker makes the network optional, but you still need a strategy for what to render first. There are three mainstream patterns, and the service worker's navigation handler is where you choose between them:

  1. App shell (SPA). Precache a minimal HTML shell plus its CSS and JavaScript; answer every in-app navigation with the cached shell, then fetch content as data. Repeat visits paint instantly and work offline by construction. The cost is a client-rendered first view and JavaScript on the critical path. See App Shell Model.
  2. Server-rendered pages (MPA) with a network-first navigation handler. Every navigation goes to the network — sped up with Navigation Preload — and falls back to a cached copy or an offline page. HTML is always fresh; offline coverage is limited to what you cached. See SPA vs MPA PWAs.
  3. Streaming composition. The service worker streams a cached header immediately, then the network body, then a cached footer, combining the fast first paint of an app shell with server-rendered content. See Streaming Responses.

The SPA variant needs a careful allowlist so the shell is not returned for URLs the SPA does not own:

sw.js (navigation handling for an app-shell SPA)
const SHELL_URL = "/index.html";
// Never answer these navigations with the SPA shell.
const SHELL_DENYLIST = [/^\/api\//, /^\/auth\//, /^\/admin\//, /\.[a-z0-9]+$/i];

self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.mode !== "navigate") return;

  const url = new URL(request.url);
  if (url.origin !== self.location.origin) return;
  if (SHELL_DENYLIST.some((re) => re.test(url.pathname))) return;

  event.respondWith(
    (async () => {
      const shell = await caches.match(SHELL_URL);
      if (shell) return shell;
      // Shell missing (evicted or first install failed): use the network.
      return fetch(request);
    })(),
  );
});

Rendering performance beyond the service worker — critical CSS, code splitting, hydration cost — is covered in Loading Performance and Runtime Performance.

Layer 6: The optional capability layer

Everything above makes an app fast, reliable and installable. Capabilities make it engaging and integrated, but every one of them must be treated as progressive enhancement because support differs sharply between engines.

Capability API surface Where the code runs Requires installation? Support summary Deep dive
Push messages PushManager.subscribe(), push event Service worker iOS/iPadOS: yes; elsewhere no All engines Push Notifications
Notifications showNotification(), notificationclick Service worker (and page) iOS/iPadOS: yes All engines Notifications API
App badge navigator.setAppBadge() Page or service worker Usually Chromium desktop, Safari (installed apps) Badging API
One-off background sync registration.sync.register(), sync event Service worker No Chromium only Background Sync
Periodic sync registration.periodicSync.register() Service worker Yes Chromium only Periodic Background Sync
Background downloads registration.backgroundFetch.fetch() Service worker No Chromium only Background Fetch
Share out navigator.share() Page No All engines (Firefox desktop behind a flag) Web Share API
Receive shares share_target manifest member Page or service worker (POST) Yes Chromium on Android and ChromeOS Web Share Target
Open files from the OS file_handlers + launchQueue Page Yes Chromium desktop File Handling
Custom URL schemes protocol_handlers Page Yes (manifest form) Chromium desktop Protocol Handlers & Launch Handling
Launcher shortcuts shortcuts manifest member n/a (OS menu) Yes Chromium; Safari on macOS App Shortcuts

The pattern for adding any of them is identical: detect, then enhance, and keep the core flow working without them.

src/enhance.js
import { registerServiceWorker } from "./register-sw.js";

export async function enhance() {
  const registration = await registerServiceWorker({
    onUpdateReady: (activate) => showUpdateBanner(activate),
  });
  if (!registration) return; // plain website mode: everything still works

  // Badging: reflect unread count on the app icon where supported.
  if ("setAppBadge" in navigator) {
    const unread = await getUnreadCount();
    navigator.setAppBadge(unread).catch(() => {}); // rejects if not permitted
  }

  // Background Sync: retry the outbox when connectivity returns (Chromium);
  // other engines fall back to the "online" event in the page.
  // sync.register() rejects with InvalidStateError until the registration has an
  // *active* worker, and register() resolves while the worker is still installing,
  // so wait for navigator.serviceWorker.ready. In a real app, register the tag
  // right after queueing a write, not unconditionally at startup.
  const active = await navigator.serviceWorker.ready;
  if ("sync" in active) {
    await active.sync.register("flush-outbox").catch(() => {});
  } else {
    addEventListener("online", () => flushOutboxFromPage());
  }

  // Web Share: show share buttons only where they work.
  document.documentElement.classList.toggle("can-share", typeof navigator.share === "function");
}

// App-specific helpers (implemented elsewhere in the app).
function showUpdateBanner(activate) { /* render UI, call activate() on click */ }
async function getUnreadCount() { return 0; }
async function flushOutboxFromPage() { /* replay queued requests */ }

Request flows in detail

The four flows below trace real requests through the blocks. They assume the minimal implementation later on this page: a precached shell and offline page, network-first navigations with navigation preload, and cache-first static assets.

First visit

On the first visit nothing is controlled yet; the service worker is installed in the background and only affects later loads (unless it claims clients).

sequenceDiagram
    participant U as User
    participant P as Page
    participant B as Browser
    participant S as Server
    participant W as Service worker
    participant C as Cache Storage
    U->>B: Open https://notes.example/
    B->>S: GET / (no controller, normal HTTP cache rules)
    S-->>B: 200 HTML
    B->>S: GET CSS, JS, icons
    P->>B: register /sw.js after load event
    B->>S: GET /sw.js
    B->>W: install event
    W->>S: fetch precache list with cache reload
    W->>C: cache.addAll into precache-v1
    B->>W: activate event
    W->>C: delete caches from older versions
    W->>B: enable navigation preload, clients.claim
    B->>S: GET /manifest.webmanifest when evaluating installability

Key details:

  • Register after the load event (or when the page is idle) so the worker's precache downloads do not compete with the first render.
  • Precache requests use cache: "reload" so a stale HTTP-cached copy is not frozen into Cache Storage.
  • If any precache request fails, addAll() rejects, the install fails, and the worker becomes redundant. The browser retries on the next navigation. Keep the precache list small and reliable.
  • The page may be claimed at the end of activation. It was already rendered from the network, so claiming only affects its subsequent requests.

Repeat visit

sequenceDiagram
    participant U as User
    participant B as Browser
    participant W as Service worker
    participant C as Cache Storage
    participant S as Server
    U->>B: Launch installed app or open URL
    B->>B: Find registration by longest scope match
    par Start worker
        B->>W: Start service worker if not running
    and Navigation preload
        B->>S: GET / with Service-Worker-Navigation-Preload header
    end
    B->>W: fetch event for navigation
    W->>W: await event.preloadResponse
    S-->>W: 200 HTML
    W-->>B: respondWith network HTML
    B->>W: fetch events for CSS and JS
    W->>C: caches.match
    C-->>W: precached responses
    W-->>B: respondWith cached assets
    B->>S: Update check for /sw.js after navigation

Navigation preload removes most of the service worker startup cost for network-first navigations: the browser issues the request in parallel with booting the worker and hands it to the worker as event.preloadResponse. The server can recognize these requests by the Service-Worker-Navigation-Preload header. Static assets never touch the network, and the update check for sw.js happens after the navigation without blocking it.

Offline visit

sequenceDiagram
    participant U as User
    participant B as Browser
    participant W as Service worker
    participant C as Cache Storage
    participant D as IndexedDB
    U->>B: Launch app with no connectivity
    B->>W: fetch event for navigation
    W->>W: preload or fetch rejects with TypeError
    W->>C: caches.match for the request, else offline page
    C-->>W: cached page or /offline.html
    W-->>B: respondWith fallback
    B->>W: fetch events for assets
    W->>C: caches.match
    C-->>W: precached assets
    U->>B: Create a note
    B->>D: Store note and outbox entry
    B->>W: sync.register flush-outbox where supported
    Note over W,D: When online again the sync event or the online listener replays the outbox

Offline behavior is designed, not automatic: you decide which pages have cached copies, what the offline fallback says, and how writes are queued and replayed. Chromium's Background Sync can replay the outbox after the app is closed; in Safari and Firefox the page must do it while open, typically on the online event or at next launch. Patterns: Offline UX & Fallbacks and Offline-First Data & Sync.

Deploying an update

sequenceDiagram
    participant Dev as Deploy
    participant S as Server
    participant B as Browser
    participant Old as Old worker v1
    participant New as New worker v2
    participant P as Page
    Dev->>S: Upload hashed assets, then HTML and sw.js v2
    P->>B: Navigate or registration.update
    B->>S: GET /sw.js bypassing HTTP cache
    B->>B: Byte comparison differs
    B->>New: install event
    New->>S: Precache v2 assets into precache-v2
    Note over Old,P: Old worker still controls open pages
    B->>P: updatefound, new worker state installed
    P->>P: Show Update available banner
    P->>New: postMessage SKIP_WAITING after user click
    New->>New: skipWaiting
    B->>New: activate event
    New->>New: Delete precache-v1
    B->>P: controllerchange
    P->>P: location.reload

The deploy order matters: upload new hashed assets before the HTML and sw.js that reference them, and keep the previous release's assets on the server for a while. Pages still running v1 code may lazy-load v1 chunks after v2 is deployed; if those files are gone and not in a cache, the app breaks. Deleting old caches belongs in activate, when no page uses them any more — never in install, while v1 is still serving.

Reference file layout for a production PWA

Project layout (build output)
dist/
├── index.html                    # app shell or SSR entry
├── offline.html                  # offline fallback page (precached)
├── manifest.webmanifest          # application/manifest+json
├── sw.js                         # generated: precache manifest injected at build
├── assets/
│   ├── app.3f9a1c7e.js           # content-hashed, immutable
│   ├── app.8b2d4e01.css
│   └── vendor.c01f7702.js
├── icons/
│   ├── favicon.svg
│   ├── icon-192.png
│   ├── icon-512.png
│   ├── icon-maskable-512.png
│   └── apple-touch-icon.png      # 180x180, used by Safari for the Home Screen
├── screenshots/                  # for the richer install dialog
│   ├── wide-1280x720.png
│   └── narrow-720x1280.png
└── .well-known/
    └── assetlinks.json           # only if you publish a Trusted Web Activity

The HTTP headers matter as much as the files:

Path Cache-Control Other headers Why
/sw.js no-cache (or a short max-age) Content-Type: text/javascript; Service-Worker-Allowed only if the script lives below the scope it controls Update checks must see new bytes promptly
/index.html and other HTML no-cache Content-Security-Policy HTML references hashed assets; must revalidate every time
/assets/* (hashed) public, max-age=31536000, immutable — File names change when content changes
/manifest.webmanifest no-cache or short max-age Content-Type: application/manifest+json Browsers re-check it for updates of installed apps
/icons/*, /screenshots/* Long max-age if URLs are versioned — Changing icons in place confuses update detection
Every response — Strict-Transport-Security Never downgrade to HTTP

An nginx configuration implementing the table:

nginx.conf (server block excerpt)
# nginx does not inherit add_header from the server level into a location
# that defines its own add_header, so repeat HSTS in each location.
location = /sw.js {
    add_header Cache-Control "no-cache" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}

location = /manifest.webmanifest {
    types { application/manifest+json webmanifest; }
    add_header Cache-Control "no-cache" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}

location / {
    # SPA fallback; for an MPA, remove try_files' /index.html fallback.
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
}

A minimal but correct implementation

The following five files (plus styles.css and the icons) form a complete PWA: installable in Chromium, addable to the Home Screen on iOS, fast on repeat visits and usable offline. "Correct" means it handles the traps that most tutorials skip: stale precache from the HTTP cache, old caches never deleted, navigation preload enabled but unused, non-GET and cross-origin requests hijacked, and update activation without user consent. The complete walk-through with explanations is the Tutorial: Your First PWA.

index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Field Notes</title>
  <meta name="theme-color" content="#0b57d0">
  <link rel="manifest" href="/manifest.webmanifest">
  <link rel="icon" href="/icons/favicon.svg" type="image/svg+xml">
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
  <link rel="stylesheet" href="/styles.css">
  <script type="module" src="/app.js"></script>
</head>
<body>
  <main id="app">
    <h1>Field Notes</h1>
    <p id="status" role="status" aria-live="polite"></p>
  </main>
  <div id="update-banner" hidden>
    A new version is available. <button type="button" id="update-button">Reload</button>
  </div>
</body>
</html>

The manifest is the one shown in Layer 2. The page script registers the worker and wires up the update banner:

app.js
const status = document.getElementById("status");

function renderConnectivity() {
  status.textContent = navigator.onLine ? "" : "You are offline. Changes are saved on this device.";
}
addEventListener("online", renderConnectivity);
addEventListener("offline", renderConnectivity);
renderConnectivity();

async function registerServiceWorker() {
  if (!("serviceWorker" in navigator)) return; // not a secure context or unsupported

  const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
  let updateAccepted = false;

  const offerUpdate = (worker) => {
    if (!worker || !navigator.serviceWorker.controller) return; // first install
    const banner = document.getElementById("update-banner");
    banner.hidden = false;
    document.getElementById("update-button").addEventListener(
      "click",
      () => {
        updateAccepted = true;
        worker.postMessage({ type: "SKIP_WAITING" });
      },
      { once: true },
    );
  };

  offerUpdate(registration.waiting);
  registration.addEventListener("updatefound", () => {
    const worker = registration.installing;
    worker?.addEventListener("statechange", () => {
      if (worker.state === "installed") offerUpdate(worker);
    });
  });

  let refreshing = false;
  navigator.serviceWorker.addEventListener("controllerchange", () => {
    // Guard against reload loops (see service-workers/pitfalls.md).
    if (!updateAccepted || refreshing) return;
    refreshing = true;
    location.reload();
  });
}

// Register after load so precaching does not compete with first render.
if (document.readyState === "complete") {
  registerServiceWorker().catch((err) => console.error("SW registration failed", err));
} else {
  addEventListener("load", () => {
    registerServiceWorker().catch((err) => console.error("SW registration failed", err));
  });
}

The service worker precaches the page, script, styles, manifest, icons and offline.html on install (with cache: "reload" so no stale HTTP-cache copy is stored), deletes old precache-* caches and enables navigation preload on activate, and calls skipWaiting() only when the page sends SKIP_WAITING. The complete worker is built and explained line by line in Build Your First PWA; the routing core is:

sw.js (excerpt)
self.addEventListener("fetch", (event) => {
  const { request } = event;
  // Only handle same-origin GETs; let everything else hit the network untouched.
  if (request.method !== "GET") return;
  const url = new URL(request.url);
  if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;

  event.respondWith(
    request.mode === "navigate" ? networkFirstNavigation(event) : cacheFirst(request),
  );
});

async function networkFirstNavigation(event) {
  try {
    return (await event.preloadResponse) ?? (await fetch(event.request));
  } catch {
    // Offline: cached copy of this page (ignoring ?source=pwa on start_url), else offline page.
    const cached = await caches.match(event.request, { ignoreSearch: true });
    return cached ?? (await caches.match(OFFLINE_URL)) ?? Response.error();
  }
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  // Cache successful, basic (same-origin, non-opaque) responses for next time.
  if (response.ok && response.type === "basic") {
    const cache = await caches.open(RUNTIME);
    cache.put(request, response.clone()).catch(() => {}); // quota errors are non-fatal
  }
  return response;
}

Strategy trade-offs (and why navigations and assets are handled differently) are covered in Caching Strategies.

offline.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Offline · Field Notes</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <main>
    <h1>You are offline</h1>
    <p>This page is not available offline. Your saved notes are still on this device.</p>
    <p><a href="/">Go to your notes</a></p>
  </main>
</body>
</html>

The runtime cache in this minimal version never expires

cacheFirst() stores every same-origin asset it sees in runtime-v1 forever. That is acceptable for a small site with a handful of files, but a real app needs limits (maximum entries and age) and must not cache responses that vary per user. Production setups use Workbox expiration or equivalent logic — see Workbox Fundamentals.

What production adds

Concern Minimal version Production version
Precache list Hand-written array, one global VERSION Generated at build time with a revision hash per file, so only changed files are re-downloaded (Workbox, Vite PWA Plugin)
Runtime caching Cache-first, unbounded Per-route strategies (stale-while-revalidate for avatars, network-first for API reads), expiration by count and age, cacheable-status filters
Navigations Network-first with preload and offline page Timeouts for slow networks, streamed shells, per-route offline pages, SSR integration
Updates Banner and reload Multi-tab coordination, skip prompts for trivial releases, version negotiation with the API, release notes
Data None IndexedDB schema migrations, outbox with idempotency keys, conflict resolution, Background Sync with page fallback
Push None VAPID keys, subscription storage, pushsubscriptionchange handling, per-platform permission UX (iOS requires Home Screen install)
Security HTTPS CSP, HSTS preload, least-privilege scope, strict Cache-Control for sw.js, validation of messages from pages (Service Worker Security)
Recovery None A pre-built kill-switch worker, Clear-Site-Data as last resort
Observability Console logs RUM for Core Web Vitals, service worker error reporting, cache hit ratio, install and update analytics (Analytics for PWAs)
Testing Manual Automated offline and update-flow tests, Lighthouse CI budgets (Automated Testing)

The kill switch deserves special mention because you cannot delete a service worker from the server side: once installed, a broken worker keeps serving until a new sw.js replaces it. Keep this file ready to deploy at the same URL:

sw.js (kill switch)
// Deploy this at the same URL as the broken worker. It installs immediately,
// wipes this origin's caches, unregisters itself and reloads open windows so
// they come back from the network without a service worker.
self.addEventListener("install", () => self.skipWaiting());

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      await Promise.all(keys.map((key) => caches.delete(key)));
      await self.registration.unregister();
      const windows = await self.clients.matchAll({ type: "window" });
      await Promise.all(windows.map((client) => client.navigate(client.url)));
    })(),
  );
});

The final checklist before launch lives in the Production Checklist.

Common pitfalls

  • Serving sw.js with a long max-age. Browsers bypass their own HTTP cache for the main worker script by default (updateViaCache: "imports"), but CDNs and proxies honor max-age, so an edge cache can keep serving the old worker to everyone; opting into updateViaCache: "all" re-enables browser caching for up to 24 hours. Serve sw.js with no-cache and purge it on deploy.
  • Registering from a subdirectory. /js/sw.js can only control /js/ unless the response sends Service-Worker-Allowed: /. Put it at the root.
  • Calling skipWaiting() unconditionally in install. The new worker then takes over pages that loaded old HTML and JavaScript, which can request files that no longer exist. Activate on user consent or when no page is at risk.
  • Deleting caches in install. The old worker still controls pages and expects its cache. Clean up in activate.
  • Enabling navigation preload without using event.preloadResponse. The browser makes the preload request anyway, so you double-fetch every navigation.
  • Caching opaque or personalized responses. Opaque responses can hide errors and cost disproportionate quota; per-user API responses cached under a shared URL leak data between accounts on shared devices.
  • Putting the manifest link in an iframe or behind an authenticated CDN without crossorigin="use-credentials". The browser silently ignores or fails to fetch it, and the app is not installable.
  • Changing start_url without a fixed id. The app's identity changes and installed users can end up with duplicates.
  • Precaching / but launching /?source=pwa. caches.match() compares full URLs including the query string, so an installed app whose start_url carries a tracking parameter misses the precached shell when it launches offline. Match the fallback with ignoreSearch: true (as in the minimal worker above) or precache the exact start_url.
  • Assuming the first page load is controlled. Without clients.claim(), it is not; with it, the page's earlier requests still went to the network.

Debugging

Every engine exposes the building blocks in its developer tools: Chromium's Application panel shows the manifest as parsed (with installability errors), service worker state (including "Update on reload" and "Bypass for network"), Cache Storage, IndexedDB and quota usage; chrome://serviceworker-internals lists every registration. Firefox shows workers at about:debugging#/runtime/this-firefox. Safari's Web Inspector can inspect service workers, and Safari 26 added automatic inspection and pausing of service workers for debugging push and other background events (WebKit: Safari 26.0). Step-by-step workflows are in Browser DevTools and Lighthouse & Auditing.

Further reading

On this site

External references