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
crossoriginattribute. - 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
localStoragefor 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
httpsorwss(orfile, which browsers treat inconsistently for powerful features); - a host in
127.0.0.0/8or::1/128; - the host
localhostorlocalhost., or any host ending in.localhostor.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:
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.
How the manifest link is fetched¶
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 whoserelcontainsmanifestis used. - The request's destination is
manifest, its mode iscors, and its credentials mode comes from the element'scrossoriginattribute: absent oranonymousmeanssame-origin(cookies are sent to your own origin but not to a CDN on another origin), whileuse-credentialsmeansinclude. A manifest on a different origin must therefore be served withAccess-Control-Allow-Origin, and a manifest that sits behind cookie authentication on another origin needscrossorigin="use-credentials". - The response must have a JSON MIME type; the registered type is
application/manifest+jsonwith the.webmanifestextension, though any JSON type is accepted. - The fetch does not delay the
loadevent. 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 itshrefchanges.
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¶
{
"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:
<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,windowandlocalStoragedo not exist;caches,indexedDB,fetch,clientsandregistrationdo. Communicate with pages throughpostMessage(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)andevent.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
fetchlistener 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:
- The page must be a secure context and the script URL must be same-origin with the page.
- The script must be served with a JavaScript MIME type; anything else rejects with a
SecurityError. Redirects are not followed for the worker script. - The default
scopeis the script's directory. The maximum scope is also the script's directory unless the response carries aService-Worker-Allowedheader naming a broader path. Keepsw.jsat the site root to control the whole origin. type: "module"enables ES module syntax (import) in the worker; module service workers are supported in Chromium 91+, Safari 15+ and Firefox 147+.updateViaCachecontrols 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:
/**
* 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, unlessignoreVaryis set, honors the stored response'sVaryheader 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()rejects206 Partial Contentresponses and responses withVary: *.- Opaque responses (cross-origin
no-corsfetches) 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:
- 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.
- 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.
- 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:
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.
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
loadevent (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¶
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 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.
<!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:
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:
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.
<!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:
// 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.jswith a longmax-age. Browsers bypass their own HTTP cache for the main worker script by default (updateViaCache: "imports"), but CDNs and proxies honormax-age, so an edge cache can keep serving the old worker to everyone; opting intoupdateViaCache: "all"re-enables browser caching for up to 24 hours. Servesw.jswithno-cacheand purge it on deploy. - Registering from a subdirectory.
/js/sw.jscan only control/js/unless the response sendsService-Worker-Allowed: /. Put it at the root. - Calling
skipWaiting()unconditionally ininstall. 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 inactivate. - 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_urlwithout a fixedid. 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 whosestart_urlcarries a tracking parameter misses the precached shell when it launches offline. Match the fallback withignoreSearch: true(as in the minimal worker above) or precache the exactstart_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
- What Is a PWA?
- PWA vs Native vs Hybrid
- Installability Criteria
- Tutorial: Your First PWA
- Service Worker Lifecycle
- Caching Strategies
- Storage Quotas & Persistence
- App Shell Model
External references