Skip to content

PWA API Cheat Sheet

This page lists the JavaScript surface of Progressive Web Apps: every interface, method, option and event you touch when you register a service worker, cache responses, message clients, send push notifications, sync in the background, badge an icon, share content, handle launches or promote installation. Each entry gives the exact signature from the specification's Web IDL, the defaults and constraints that matter, the exceptions it throws, a one-line example and its browser support as of September 2026. It is a lookup page. The mechanisms, full examples and edge cases are on the linked topic pages, and the manifest has its own Manifest Cheat Sheet.

Key takeaways

  • Everything here requires a secure context (HTTPS or localhost). Most service worker interfaces are exposed to Window and workers. Clients, FetchEvent and the other event types exist only inside the service worker.
  • The core works in all current engines: registration, lifecycle, fetch interception, Cache Storage, Clients, messaging, navigation preload, Push and Notifications. On iOS and iPadOS, Push, Notifications and Badging work only in Home Screen web apps.
  • Chromium-only APIs: Background Sync, Periodic Background Sync, Background Fetch, beforeinstallprompt, getInstalledRelatedApps(), launchQueue, Window Controls Overlay and Content Index. Always feature-detect them and build a fallback.
  • Changes from 2025 and 2026: Declarative Web Push and window.pushManager (Safari 18.4 on iOS, 18.5 on macOS), pushsubscriptionchange in Chrome 138, module service workers in Firefox 147, notification actions in Firefox 152, and Static Routing (InstallEvent.addRoutes(), in Chrome since 123) in Safari 27. Chrome 149 restricts Background Fetch started from a service worker, and Chrome Platform Status lists CORS and Local Network Access enforcement on it for Chrome 154.
  • navigator.install() and the <install> element are experimental and desktop-only. After origin trials (navigator.install() in Chrome 143 to 148, extended through 150; <install> in Chrome 148 to 153, now ended), an Intent to Ship posted in September 2026 targets desktop Chrome 156. There is no Android implementation.
  • Most methods return promises that reject with a DOMException. The exceptions table at the end maps each error name to its usual cause.

How to read this cheat sheet

Each API section follows the same pattern: a table of members with their Web IDL signature, what they return or do, and the constraints that trip people up, followed by one-line examples and a support row. The conventions are:

Notation Meaning
W Exposed on Window (pages)
SW Exposed in ServiceWorkerGlobalScope only
W+Wk Exposed on Window and in workers (dedicated, shared and service workers)
optional X = {} The argument can be omitted; the default is shown
[SameObject] The getter returns the same object each time
[NewObject] Every call returns a new promise or object; never compare them with ===
Extendable The event is an ExtendableEvent: call event.waitUntil(promise) to keep the worker alive until work completes
✅ / ❌ / ⚠️ / 🧪 Supported / not supported / partial (explained in a note) / behind a flag or origin trial

Version numbers are the first stable release with full support, taken from MDN's browser-compat-data (the September 24, 2026 release) and from browser release notes. "Chrome" includes Edge, Opera and Samsung Internet at the matching Chromium version unless a note says otherwise. "Safari iOS" means Safari and every other browser on iOS and iPadOS, because they all use WebKit.

Support at a glance

Support data as of September 2026. Check MDN and caniuse for live data.

API Chrome desktop Chrome Android Firefox Safari macOS Safari iOS / iPadOS
Service workers (register, lifecycle, fetch) ✅ 40 ✅ 40 ✅ 44 ✅ 11.1 ✅ 11.3
Module service workers (type: "module") ✅ 91 ✅ 91 ✅ 147 ✅ 15 ✅ 15
Cache Storage (caches, Cache) ✅ 43 ✅ 43 ✅ 41 ✅ 11.1 ✅ 11.3
Navigation preload ✅ 59 ✅ 59 ✅ 99 ✅ 15.4 ✅ 15.4
FetchEvent.resultingClientId ✅ 72 ✅ 72 ✅ 65 ✅ 16 ✅ 16
FetchEvent.handled ✅ 86 ✅ 86 ✅ 84 ✅ 16 ✅ 16
Static Routing (InstallEvent.addRoutes()) ✅ 123 ✅ 123 ❌ ✅ 27 ✅ 27
Clients, WindowClient.focus() ✅ 42 ✅ 42 ✅ 44 ✅ 11.1 ✅ 11.3
WindowClient.navigate() ✅ 49 ✅ 49 ✅ 50 ✅ 16 ✅ 16
Push API (PushManager, push event) ✅ 42 ✅ 42 ✅ 44 ✅ 16 ⚠️ 16.4
Declarative Web Push, window.pushManager ❌ ❌ 🧪 ✅ 18.51 ⚠️ 18.4
showNotification() ✅ 42 ✅ 42 ✅ 44 ✅ 16 ⚠️ 16.4
Notification actions ✅ 48 ✅ 48 ✅ 152 ❌ ❌
Badging (setAppBadge()) ✅ 81 ❌ ❌ ✅ 17 ⚠️ 16.4
Background Sync (sync) ✅ 49 ✅ 49 ❌ ❌ ❌
Periodic Background Sync ✅ 80 ✅ 80 ❌ ❌ ❌
Background Fetch ✅ 74 ✅ 74 ❌ ❌ ❌
navigator.storage.persist() ✅ 55 ✅ 55 ✅ 57 ✅ 15.2 ✅ 15.2
navigator.storage.estimate() ✅ 61 ✅ 61 ✅ 57 ✅ 17 ✅ 17
OPFS (navigator.storage.getDirectory()) ✅ 86 ✅ 109 ✅ 111 ✅ 15.2 ✅ 15.2
Storage Buckets ✅ 122 ✅ 122 ❌ ❌ ❌
Cookie Store API (cookieStore) ✅ 87 ✅ 87 ✅ 140 ✅ 18.4 ✅ 18.4
Web Share (navigator.share()) ⚠️ 89 ✅ 61 🧪 ✅ 12.1 ✅ 12.2
Web Share with files ⚠️ 89 ✅ 76 ❌ ✅ 14 ✅ 14
beforeinstallprompt, appinstalled ✅ ✅ ❌ ❌ ❌
navigator.getInstalledRelatedApps() ⚠️ 85 ✅ 80 ❌ ❌ ❌
launchQueue (Launch Handler, File Handling) ✅ 102 ❌ ❌ ❌ ❌
Window Controls Overlay ✅ 105 ❌ ❌ ❌ ❌
navigator.install(), <install> 🧪 ❌ ❌ ❌ ❌
Content Index (registration.index) ❌ ✅ 84 ❌ ❌ ❌
@media (display-mode: standalone) ✅ 42 ✅ 42 ⚠️ 57 ✅ 13 ⚠️ 12.2

Notes on the ⚠️ entries:

  • iOS and iPadOS Push, Notifications and Badging work only in web apps added to the Home Screen, and only after the user grants notification permission from a user gesture inside that app. Since iOS 26, any site added with Open as Web App (on by default) is a web app, so no manifest or manifest display value is required. Safari tabs on iOS have no Notification object and no PushManager. See Web Push on iOS & Safari.
  • Chrome desktop Web Share shipped on Windows and ChromeOS in Chrome 89 and on macOS in Chrome 128. Chrome on Linux has no share implementation. Firefox desktop has navigator.share() only behind the dom.webshare.enabled preference; Firefox for Android supports it from version 79 without files.
  • Chrome desktop getInstalledRelatedApps() reported only Windows (UWP) apps from Chrome 85; Chrome 140 added detection of installed desktop web apps in the same scope. On Android, Chrome 80 detects Play Store apps and Chrome 84 detects installed PWAs.
  • Firefox Declarative Web Push is implemented behind the dom.push.declarative.enabled preference and is off by default.
  • Firefox display-mode: standalone parses from Firefox 57 but never matches on desktop, which has no standalone app window (the Windows taskbar web apps of Firefox 143+ are covered under minimal-ui below); Firefox for Android matches it from version 116.
  • iOS display-mode is partial: in a Home Screen web app whose manifest says "display": "standalone", (display-mode: standalone) is false and (display-mode: fullscreen) is true (WebKit bug 264218), and minimal-ui never matches. A site added without a manifest reports browser. Check navigator.standalone === true first on Apple platforms, as described in Detecting Installed Apps.

Feature detection checks per API

Detect each API where you use it rather than sniffing user agents. Several APIs exist as objects even where they do nothing (Chrome on Linux resolves setAppBadge() without showing a badge, because Linux has no system badging API), so detection only tells you the call is safe, not that the user sees an effect.

One check per API (each is side-effect free and safe during page load; hasSW is "serviceWorker" in navigator):

Feature Check
Service worker, Cache Storage "serviceWorker" in navigator, "caches" in globalThis
Navigation preload hasSW && "navigationPreload" in ServiceWorkerRegistration.prototype
Push, Declarative Web Push hasSW && "PushManager" in globalThis; "pushManager" in window
Notifications, actions "Notification" in globalThis; Notification.maxActions > 0
Badging "setAppBadge" in navigator
Background Sync, Periodic Sync, Background Fetch "SyncManager", "PeriodicSyncManager", "BackgroundFetchManager" in globalThis (with hasSW)
Storage persistence, estimate, OPFS, buckets "persist", "estimate", "getDirectory" in navigator.storage; "storageBuckets" in navigator
Cookie Store "cookieStore" in globalThis
Web Share (files) "share" in navigator; navigator.canShare?.({ files }) per payload
Launch Queue, Window Controls Overlay "launchQueue" in window; "windowControlsOverlay" in navigator
Install prompt, related apps, Web Install "onbeforeinstallprompt" in window; "getInstalledRelatedApps" in navigator; "install" in navigator
Content Index hasSW && "ContentIndex" in globalThis
Installed context (runtime state) navigator.standalone === true first, then (display-mode: standalone) / fullscreen media queries

The complete, commented detection module lives in A capability detection module on the Capabilities overview; use that as the single source rather than copying these checks.

Two detection traps: Notification.maxActions is undefined where actions are not supported (not 0), and "share" in navigator is true in Firefox desktop only when the preference is enabled. Detecting "onbeforeinstallprompt" in window tells you the browser can fire the event, not that it will. It fires only when the page meets the installability criteria and the app is not already installed.

ServiceWorkerContainer (navigator.serviceWorker)

navigator.serviceWorker is the page's handle on registrations and on the worker that controls it. It is [SecureContext] and exposed on Window and in workers. Chrome does not yet expose it inside workers; Firefox 133+ and Safari do.

Member Signature / type Returns / behavior
register() register((TrustedScriptURL or USVString) scriptURL, optional RegistrationOptions options = {}) Promise<ServiceWorkerRegistration>. Creates or updates the registration for the scope. Resolves once the script has been fetched and evaluated without error, before the install event finishes
getRegistration() getRegistration(optional USVString clientURL = "") Promise<ServiceWorkerRegistration or undefined> for the registration whose scope matches clientURL (default: the page URL)
getRegistrations() getRegistrations() Promise<FrozenArray<ServiceWorkerRegistration>> for every registration of the storage key
ready readonly attribute Promise<ServiceWorkerRegistration> Resolves when a registration matching this page has an active worker. Never rejects; stays pending if nothing ever matches
controller readonly attribute ServiceWorker? The active worker controlling this page, or null (first load, hard reload with Shift, or out of scope)
startMessages() startMessages() Enables the client message queue early (see below)
controllerchange event Fires when controller changes: after clients.claim() or when a waiting worker activates and takes over
message event (MessageEvent) A message from a service worker via Client.postMessage(). event.source is the sending ServiceWorker
messageerror event A message arrived that could not be deserialized

RegistrationOptions:

Option Type / default Meaning
scope USVString, default "./" resolved against the script URL The URL prefix this registration controls. It cannot be wider than the script's directory unless the script response carries Service-Worker-Allowed
type "classic" (default) or "module" "module" loads the worker as an ES module (static import allowed, importScripts() throws). Top-level await is not allowed: an async module rejects registration with TypeError
updateViaCache "imports" (default), "all" or "none" Whether update checks may use the HTTP cache. "imports": the main script always bypasses it, imported scripts may use it. "none": nothing uses it. "all": both may use it (still capped at 24 hours)

register() rejections, from the specification's Register and Update algorithms:

Error Cause
SecurityError Script origin not potentially trustworthy; script or scope URL not same-origin with the page; script served with a non-JavaScript MIME type; scope outside the maximum scope (the script's directory, or the path in Service-Worker-Allowed)
TypeError Script or scope URL not http:/https:; scope or script path contains %2f or %5c; the script fetch failed or returned a non-OK status; the script threw during its first evaluation; redirect on the script request; async module
TypeError (Trusted Types) With a Trusted Types CSP, passing a plain string instead of a TrustedScriptURL (enforced in Chrome 140 and Safari 26)

One-liners:

register.js
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
const readyReg = await navigator.serviceWorker.ready;           // active worker for this page
const isControlled = navigator.serviceWorker.controller !== null;
let refreshing = false; // guard: controllerchange can fire more than once (and reload loops with DevTools "Update on reload")
navigator.serviceWorker.addEventListener("controllerchange", () => {
  if (refreshing) return;
  refreshing = true;
  location.reload();
});
navigator.serviceWorker.onmessage = (e) => console.log("from SW", e.data); // also starts the queue

The client message queue

Messages from the service worker to a page wait in a queue that starts disabled. It is enabled the first time you assign navigator.serviceWorker.onmessage, when you call startMessages(), or after the document finishes parsing. addEventListener("message", …) alone does not enable it, so early messages sit in the queue until DOMContentLoaded. Details: Messaging & the Clients API.

The full registration rules, scope resolution and the Service-Worker-Allowed header are on Registration & Scope.

ServiceWorkerRegistration

A ServiceWorkerRegistration binds a scope to up to three worker versions. You get one from register(), ready, getRegistration() or, inside the worker, self.registration. Exposed on W+Wk.

Member Signature / type Notes
installing ServiceWorker? The worker running its install event
waiting ServiceWorker? An installed worker waiting for the current one to lose all clients
active ServiceWorker? The worker in activating or activated state
scope USVString The absolute scope URL
updateViaCache "imports", "all" or "none" As registered
navigationPreload NavigationPreloadManager [SameObject] See NavigationPreloadManager
update() update() → Promise<ServiceWorkerRegistration> Fetches the script and imports, bypassing the HTTP cache per updateViaCache. Resolves when the check completes; a new worker, if any, appears in installing. Rejects with InvalidStateError when the registration has no worker yet or when called from a worker that is still installing, and with TypeError when the script fetch fails
unregister() unregister() → Promise<boolean> Marks the registration for removal. Controlled pages keep their controller until they close; true if a registration was found
updatefound event A new installing worker was created
showNotification() showNotification(title, optional NotificationOptions options = {}) → Promise<undefined> Persistent notification tied to the registration. See Notifications
getNotifications() getNotifications(optional GetNotificationOptions filter = {}) → Promise<sequence<Notification>> Open notifications of this registration; filter.tag narrows by tag
pushManager PushManager See PushManager
sync SyncManager Chromium only. See Background Sync
periodicSync PeriodicSyncManager Chromium only
backgroundFetch BackgroundFetchManager Chromium only
index ContentIndex Chrome Android only
cookies CookieStoreManager Subscribe the worker to cookie changes. Chrome 87, Firefox 140; not Safari
paymentManager PaymentManager Chromium only; payment handler registration. See Payments
registration.js
const reg = await navigator.serviceWorker.getRegistration();
reg?.addEventListener("updatefound", () => trackInstalling(reg.installing));
await reg?.update();                        // manual update check (e.g., on visibilitychange)
reg?.waiting?.postMessage({ type: "SKIP_WAITING" }); // ask the waiting worker to take over
const removed = await reg?.unregister();    // kill switch

Browsers also check for updates on their own: on every navigation to an in-scope page, on functional events such as push and sync and on subresource fetches only if no check ran in the last 24 hours, and when register() is called with a different script URL. There is no periodic 24-hour timer. Since Chrome 68 the main script is revalidated with the HTTP cache bypassed by default (updateViaCache: "imports"), so the old 24-hour HTTP cache cap matters only with updateViaCache: "all". The update flow and UI patterns are on Updating Service Workers.

ServiceWorker

A ServiceWorker object represents one worker version. You see it as registration.installing|waiting|active, navigator.serviceWorker.controller, event.source of a message event on the page, and self.serviceWorker inside the worker. Exposed on W+Wk.

Member Signature / type Notes
scriptURL USVString The script URL passed to register()
state ServiceWorkerState "parsed", "installing", "installed", "activating", "activated" or "redundant"
postMessage() postMessage(message, transfer) or postMessage(message, optional StructuredSerializeOptions options = {}) Structured-clones message into the worker's message event (an ExtendableMessageEvent). Starts the worker if needed
statechange event state changed
error event From the AbstractWorker mixin; rarely fired in practice
stateDiagram-v2
    [*] --> parsed
    parsed --> installing
    installing --> installed: "install waitUntil resolved"
    installing --> redundant: "install failed"
    installed --> activating: "no clients or skipWaiting()"
    activating --> activated
    installed --> redundant: "replaced by newer worker"
    activated --> redundant: "replaced or unregistered"

self.serviceWorker (the worker's own ServiceWorker object, useful to read self.serviceWorker.state) ships in Chrome 79 and Safari 15.4 but not in Firefox. The lifecycle behind each state is explained on Lifecycle.

ServiceWorkerGlobalScope

self inside a service worker. It extends WorkerGlobalScope, so fetch(), caches, indexedDB, crypto, importScripts() (classic only), setTimeout() and navigator (a WorkerNavigator) are available. localStorage, sessionStorage, the DOM and synchronous XHR are not.

Member Signature / type Notes
clients Clients [SameObject] See Clients
registration ServiceWorkerRegistration [SameObject] This worker's registration
serviceWorker ServiceWorker [SameObject] This worker version (Chrome 79, Safari 15.4, not Firefox)
skipWaiting() skipWaiting() → Promise<undefined> Lets an installed worker activate without waiting for old clients to close. Resolves immediately; activation happens asynchronously
cookieStore CookieStore Async cookie access in the worker (Chrome 87, Firefox 140, Safari 18.4)
importScripts() importScripts(...urls) Classic workers only. Only callable during the first evaluation and install; later calls throw NetworkError for scripts not already cached

Service worker events

Event Event interface Extendable Fires when Support
install InstallEvent ✅ Once per worker version, after the first successful evaluation. Rejected waitUntil() makes the worker redundant All
activate ExtendableEvent ✅ The worker becomes the active worker. Functional events are queued until its waitUntil() settles All
fetch FetchEvent ✅ An in-scope navigation or a subresource request from a controlled client All
message ExtendableMessageEvent ✅ A page, worker or other client calls serviceWorker.postMessage() All
messageerror MessageEvent ❌ A message failed to deserialize Chrome 81, Firefox 65, Safari partial
push PushEvent ✅ A push message arrives Chrome 42, Firefox 44, Safari 16, iOS 16.4
pushsubscriptionchange PushSubscriptionChangeEvent ✅ The push service invalidated or rotated the subscription ⚠️ See pushsubscriptionchange
notificationclick NotificationEvent ✅ The user clicks a persistent notification or one of its actions Chrome 40, Firefox 44, Safari 16
notificationclose NotificationEvent ✅ The user dismisses a notification Chrome 50, Firefox 44, Safari 16
sync SyncEvent ✅ Connectivity is available for a registered sync tag Chromium 49
periodicsync PeriodicSyncEvent ✅ The browser grants a periodic sync slot Chromium 80
backgroundfetchsuccess BackgroundFetchUpdateUIEvent ✅ All requests of a Background Fetch succeeded Chromium 74
backgroundfetchfail BackgroundFetchUpdateUIEvent ✅ At least one request failed Chromium 74
backgroundfetchabort BackgroundFetchEvent ✅ The user or code aborted the fetch Chromium 74
backgroundfetchclick BackgroundFetchEvent ✅ The user clicked the download UI Chromium 74
contentdelete ContentIndexEvent ✅ The user deleted indexed content from browser UI Chrome Android 84
cookiechange ExtendableCookieChangeEvent ✅ A subscribed cookie changed Chrome 87, Firefox 140
canmakepayment, paymentrequest CanMakePaymentEvent, PaymentRequestEvent ✅ Payment handler events Chromium 70

Two rules apply to every handler. First, add listeners synchronously during the worker's first evaluation: listeners added later (inside a promise or setTimeout) are not guaranteed to receive events, and Chrome warns about them. Second, the worker can be terminated whenever it has no pending events. Anything you need across events belongs in IndexedDB or Cache Storage, not in global variables.

sw.js (skeleton)
self.addEventListener("install", (event) => event.waitUntil(precache()));
self.addEventListener("activate", (event) => event.waitUntil(Promise.all([deleteOldCaches(), self.clients.claim()])));
self.addEventListener("fetch", (event) => { if (shouldHandle(event.request)) event.respondWith(handle(event)); });
self.addEventListener("message", (event) => { if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); });
self.addEventListener("push", (event) => event.waitUntil(showFromPush(event)));
self.addEventListener("notificationclick", (event) => event.waitUntil(openFromNotification(event)));

ExtendableEvent and waitUntil()

ExtendableEvent is the base class of every service worker event except messageerror. Its single method extends the event's lifetime.

Member Signature Behavior
waitUntil() waitUntil(Promise<any> f) → undefined Adds f to the event's extend lifetime promises. The worker stays alive (subject to browser time limits) until all settle
constructor new ExtendableEvent(type, optional ExtendableEventInit init = {}) For tests and synthetic dispatch

Rules and errors:

  • Calling waitUntil() after the event handler returned and after every previously added promise settled throws InvalidStateError. You may call it asynchronously as long as an earlier waitUntil() promise is still pending (asynchronous waitUntil(): Chrome 60, Firefox 53, Safari 11.1).
  • In install, a rejected promise makes the new worker redundant. In activate, a rejection is ignored: the worker still becomes activated.
  • In push, the promise must include showNotification() when you subscribed with userVisibleOnly: true. Otherwise Chrome shows a generic "This site has been updated in the background" notification, and Safari may revoke the subscription after repeated silent pushes.
  • Browsers cap event duration. Chromium stops a worker 30 seconds after its last event settles, terminates a worker whose event takes more than 5 minutes, gives sync and periodicsync events at most 3 minutes, and gives push a shorter custom timeout (about 90 seconds). On timeout the worker is killed. Don't rely on long work finishing.

InstallEvent and Static Routing

InstallEvent extends ExtendableEvent with the Static Routing API, which lets the browser route matching requests without starting the worker. Chrome 123 and Safari 27 ship it; Firefox does not yet, although Mozilla's standards position is positive.

Member Signature Notes
addRoutes() addRoutes((RouterRule or sequence<RouterRule>) rules) → Promise<undefined> Only callable during install. Rules are ordered; first match wins. Extends the event's lifetime by itself, as if you had called waitUntil(). Rejects with TypeError for invalid rules, when limits are exceeded, or when a rule uses "fetch-event" or "race-network-and-fetch-handler" but the worker has no fetch listener

RouterRule is { condition, source }, both required:

RouterCondition member Type Matches
urlPattern URLPattern, pattern string or URLPatternInit URL; strings and dictionaries resolve against the worker script URL
requestMethod ByteString "GET", "POST", …
requestMode RequestMode "navigate", "cors", "no-cors", "same-origin"
requestDestination RequestDestination "document", "image", "script", "style", …
runningStatus "running" or "not-running" Whether the worker is currently running
or sequence<RouterCondition> Any of the sub-conditions; must stand alone in its condition
not RouterCondition Negation; must stand alone in its condition
RouterSource Behavior
"network" Straight to the network; the worker is not started
"cache" Look up all caches; a miss falls through to the network
{ cacheName: "static-v3" } Look up one named cache; a miss falls through to the network
"fetch-event" Dispatch to the fetch handler (explicit default)
"race-network-and-fetch-handler" Start the network request and the fetch handler in parallel; the first response wins

The specification limits a worker to 1,024 conditions in total (nested ones included) and 10 levels of nesting.

sw.js (static routing)
self.addEventListener("install", (event) => {
  if (!event.addRoutes) return; // Firefox and older browsers: the fetch handler does everything
  event.waitUntil(event.addRoutes([
    { condition: { urlPattern: "/api/*", requestMethod: "GET" }, source: "network" },
    { condition: { urlPattern: "/assets/*" }, source: { cacheName: "static-v3" } },
    { condition: { requestMode: "navigate", runningStatus: "not-running" }, source: "race-network-and-fetch-handler" },
  ]));
});

Rule semantics, pitfalls such as new URLPattern() matching every origin, and the Resource Timing fields are on Static Routing API.

FetchEvent

FetchEvent is dispatched for every navigation within scope and every request from a controlled client, unless a static route handles it first. SW only.

Member Type / signature Notes
request Request [SameObject] The request. Its body can be read once
respondWith() respondWith(Promise<Response> r) Must be called synchronously during dispatch. Accepts a Response or a promise for one
preloadResponse Promise<any> Resolves to the navigation preload Response, or undefined when preload is disabled or the request is not a navigation
clientId DOMString The client that made the request; "" for navigations
resultingClientId DOMString For navigations, the id of the client the navigation will create; "" otherwise
replacesClientId DOMString In the spec; not exposed by current engines
handled Promise<undefined> Resolves when the response is handed to the browser (or the event ends without respondWith()); rejects if respondWith() got a rejected promise or a network error
waitUntil() inherited Keep the worker alive for cache writes and logging after responding

respondWith() errors and response rules:

Situation Result
Called asynchronously (after an await) Throws InvalidStateError; the browser already chose the default network behavior
Called twice Throws InvalidStateError
Promise rejects or resolves to a non-Response Network error for the page (TypeError: Failed to fetch, broken image, and so on)
Response.error() Network error
Opaque response (type: "opaque") for a request whose mode is not no-cors Network error. Navigations and fetch() calls in cors mode cannot receive opaque responses
opaqueredirect response for a request whose redirect mode is not manual Network error
Redirected response (response.redirected === true) for a request whose redirect mode is not follow Network error. Navigations use manual, so a cached response that went through a redirect breaks the page; rebuild it with new Response(r.body, r) first
Not calling respondWith() at all The browser performs the request itself, as if there were no service worker (the worker still started, which costs time)
sw.js (fetch one-liners)
event.respondWith(caches.match(event.request).then((r) => r ?? fetch(event.request)));
event.respondWith((async () => (await event.preloadResponse) ?? fetch(event.request))());
event.waitUntil(event.handled.then(() => logTiming(event.request.url)));
if (event.request.mode === "navigate") { /* HTML document request */ }
if (event.request.destination === "image") { /* <img>, CSS images, favicon */ }

Useful Request properties in a handler: mode ("navigate", "cors", "no-cors", "same-origin"), destination, method, headers, cache, credentials, redirect, url, referrer, integrity, keepalive and signal. Strategies built on these are on Handling Fetch Events and Caching Strategies.

Clients, Client and WindowClient

self.clients is the worker's view of the documents and workers it can talk to. A client is a window (top-level document or iframe), a dedicated worker or a shared worker whose storage key matches the worker's. SW only for Clients; Client and WindowClient objects are only created inside the worker.

Clients member Signature Returns / behavior
get() get(DOMString id) Promise<(Client or undefined)>. Use event.clientId or event.resultingClientId from a FetchEvent. Resolves undefined for a client that is gone or has a different storage key
matchAll() matchAll(optional ClientQueryOptions options = {}) Promise<FrozenArray<Client>>. Window clients come first, most recently focused first
openWindow() openWindow(USVString url) Promise<WindowClient?>. Opens a new top-level window. Resolves null when the opened document is cross-origin to the worker
claim() claim() Promise<undefined>. Makes this worker the controller of every in-scope client that it doesn't already control, firing controllerchange in each. Rejects with InvalidStateError unless the worker is active

ClientQueryOptions:

Option Default Meaning
includeUncontrolled false Also return same-origin clients controlled by another registration or by nothing (for example a page loaded with Shift+F5)
type "window" "window", "worker", "sharedworker" or "all". A dedicated worker inherits its owner document's controller, so without includeUncontrolled it appears only when that document is controlled
Client member Type / signature Notes
id DOMString Opaque, stable for the client's lifetime. Store it to message a specific tab later
url USVString The client's creation URL. For windows it tracks history.pushState() changes
type ClientType "window", "worker" or "sharedworker"
frameType FrameType "top-level", "nested" (iframe), "auxiliary" (opened with window.open()) or "none" (workers)
postMessage() postMessage(message, sequence<object> transfer) or postMessage(message, optional StructuredSerializeOptions options = {}) Fires message on the client's navigator.serviceWorker, not on window. Subject to the client message queue
WindowClient member Type / signature Notes
visibilityState DocumentVisibilityState "visible" or "hidden" at the time the object was created (a snapshot, not live)
focused boolean Snapshot of whether the window had focus
ancestorOrigins FrozenArray<USVString> Origins of the ancestor frames. Only Safari 16+ implements it
focus() focus() → Promise<WindowClient> Brings the window to the front. Rejects with InvalidAccessError without a window interaction allowance (in practice, outside notificationclick)
navigate() navigate(USVString url) → Promise<WindowClient?> Navigates the client. Rejects with TypeError for an invalid URL, about:blank, or a client this worker doesn't control. Resolves null when the result is cross-origin

Only notificationclick (and, in Chromium, backgroundfetchclick and payment events) grants the window interaction allowance that openWindow() and focus() need; the allowance lasts only a short, browser-defined time, so call them early in the handler rather than after slow network work. Calling either from push, message or fetch rejects with InvalidAccessError.

sw.js (clients one-liners)
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const existing = windows.find((c) => new URL(c.url).pathname === "/inbox");
await (existing ? existing.focus() : self.clients.openWindow("/inbox")); // only in notificationclick
windows.forEach((c) => c.postMessage({ type: "CACHE_UPDATED", url: "/api/feed" }));
const client = await self.clients.get(event.clientId); // the page that issued this fetch

message events delivered to the worker are ExtendableMessageEvents:

Member Type Notes
data any The structured-cloned message
origin USVString Origin of the sender
lastEventId DOMString Always "" for service worker messages
source Client, ServiceWorker, MessagePort or null Reply with event.source.postMessage()
ports FrozenArray<MessagePort> Ports transferred with the message; reply on event.ports[0] for request/response
waitUntil() inherited Keep the worker alive while you answer

Chromium stops an idle worker after 30 seconds, so a long-lived MessagePort does not keep it running. Request/response patterns, BroadcastChannel and the message queue are on Messaging & the Clients API.

CacheStorage and Cache

caches (a CacheStorage) is available on W+Wk in secure contexts. In an insecure context caches is undefined, which is the most common cause of "caches is not defined" on http:// staging hosts. Every cache is scoped to the storage key and shares the origin's quota with IndexedDB and OPFS.

CacheStorage member Signature Returns / behavior
match() match(RequestInfo request, optional MultiCacheQueryOptions options = {}) Promise<Response or undefined>. Searches caches in creation order and returns the first hit. options.cacheName restricts the search to one cache
has() has(DOMString cacheName) Promise<boolean>
open() open(DOMString cacheName) Promise<Cache>. Creates the cache if it doesn't exist
delete() delete(DOMString cacheName) Promise<boolean>. true if a cache was deleted
keys() keys() Promise<sequence<DOMString>>, in creation order
Cache member Signature Returns / behavior
match() match(RequestInfo request, optional CacheQueryOptions options = {}) Promise<(Response or undefined)> for the first match
matchAll() matchAll(optional RequestInfo request, optional CacheQueryOptions options = {}) Promise<FrozenArray<Response>>; every response when request is omitted
add() add(RequestInfo request) Promise<undefined>. Fetches and stores; equivalent to addAll([request])
addAll() addAll(sequence<RequestInfo> requests) Promise<undefined>. Fetches all, then stores all atomically. Rejects with TypeError if any response is not OK (non-2xx), is a 206, or carries Vary: *, and with InvalidStateError for duplicate requests in the list
put() put(RequestInfo request, Response response) Promise<undefined>. Stores any response, including 404s and opaque ones. Rejects with TypeError for a non-GET request, a non-http(s) URL, a 206 response, Vary: *, or a body that is already used
delete() delete(RequestInfo request, optional CacheQueryOptions options = {}) Promise<boolean>
keys() keys(optional RequestInfo request, optional CacheQueryOptions options = {}) Promise<FrozenArray<Request>>, in insertion order

CacheQueryOptions (all default to false): ignoreSearch ignores the query string of both URLs, ignoreMethod lets non-GET requests match, and ignoreVary skips the Vary header comparison. MultiCacheQueryOptions adds cacheName.

Any write can reject with QuotaExceededError. Matching never looks at Cache-Control, Expires or cookies: a stored response stays until you delete it.

cache one-liners
const cache = await caches.open("static-v3");
await cache.addAll(["/", "/app.css", "/app.js"]);                    // atomic precache
await cache.put(request, response.clone());                         // clone before you return it
const hit = await caches.match("/offline.html", { cacheName: "static-v3" });
const withoutQuery = await cache.match(request, { ignoreSearch: true });
await Promise.all((await caches.keys()).filter((n) => n !== "static-v3").map((n) => caches.delete(n)));

The matching algorithm, opaque response padding and performance advice are on Cache Storage API. Strategy code is on Caching Strategies, and quotas are on Storage Quotas & Persistence.

registration.navigationPreload lets the browser start the navigation request in parallel with booting the worker. Exposed on W+Wk; Chrome 59, Firefox 99, Safari 15.4.

Member Signature Behavior
enable() enable() → Promise<undefined> Turns preload on for the registration. Rejects with InvalidStateError when there is no active worker
disable() disable() → Promise<undefined> Turns it off. Same rejection
setHeaderValue() setHeaderValue(ByteString value) → Promise<undefined> Replaces the value of the Service-Worker-Navigation-Preload request header (default "true"). Rejects with TypeError for an invalid header value and InvalidStateError without an active worker
getState() getState() → Promise<NavigationPreloadState> { enabled: boolean, headerValue: ByteString }

The state belongs to the registration, not to the worker version, so it survives updates. Enable it in activate and always consume event.preloadResponse for navigations, otherwise you pay for a request you never use.

sw.js (navigation preload)
self.addEventListener("activate", (event) => {
  event.waitUntil(self.registration.navigationPreload?.enable());
});
self.addEventListener("fetch", (event) => {
  if (event.request.mode !== "navigate") return;
  event.respondWith((async () => {
    try {
      return (await event.preloadResponse) ?? (await fetch(event.request));
    } catch {
      return (await caches.match("/offline.html")) ?? Response.error();
    }
  })());
});

If your server returns different content for preload requests, send Vary: Service-Worker-Navigation-Preload. Details: Navigation Preload.

PushManager

registration.pushManager creates and reads push subscriptions. Exposed on W+Wk in secure contexts. Chrome 42, Firefox 44, Safari 16 on macOS and 16.4 on iOS and iPadOS (Home Screen web apps only).

Member Signature Returns / behavior
supportedContentEncodings static readonly FrozenArray<DOMString> Payload encodings the push service accepts, for example ["aes128gcm"]. Some engines also list the legacy "aesgcm". Use aes128gcm (RFC 8291)
subscribe() subscribe(optional PushSubscriptionOptionsInit options = {}) Promise<PushSubscription>. Requests the push permission if needed and returns the existing subscription if one exists with the same options
getSubscription() getSubscription() Promise<PushSubscription?>. null when not subscribed
permissionState() permissionState(optional PushSubscriptionOptionsInit options = {}) Promise<PermissionState>: "granted", "denied" or "prompt". Pass the same options you use for subscribe()

PushSubscriptionOptionsInit:

Option Type / default Meaning
userVisibleOnly boolean, default false Promise to show a notification for every push. Chromium and Safari require true and reject false with NotAllowedError; Firefox accepts false but applies a quota to background (notification-less) messages. Pass true for portable code
applicationServerKey BufferSource, base64url DOMString or null Your VAPID public key: an uncompressed P-256 point (65 bytes, starting with 0x04). Decode it to a Uint8Array for the most portable call

subscribe() rejections:

Error Cause
NotAllowedError Permission denied, call outside a user gesture (Firefox, Safari), userVisibleOnly missing (Chrome), non-HTTPS scope, or a Chrome Incognito window
InvalidStateError No active worker, or an existing subscription has a different applicationServerKey. Unsubscribe before rotating keys
InvalidCharacterError The key string is not base64url (standard base64 or = padding)
InvalidAccessError The key is not a valid P-256 point (for example, you passed the private key)
NotSupportedError The push service requires a key and none was given
AbortError Push service, network or storage failure (Chrome also uses it when no worker is active yet)

PushSubscription

Member Type / signature Notes
endpoint USVString The push service URL your server POSTs to. Treat it as a secret capability URL
expirationTime EpochTimeStamp? Milliseconds since the epoch, or null. Usually null in practice; if it is set, resubscribe before that time
options PushSubscriptionOptions { userVisibleOnly, applicationServerKey }, the key as an ArrayBuffer
getKey() getKey(PushEncryptionKeyName name) → ArrayBuffer? "p256dh" (the client's ECDH public key) or "auth" (the 16-byte auth secret)
toJSON() toJSON() → PushSubscriptionJSON { endpoint, expirationTime, keys: { p256dh, auth } } with base64url values; this is what you send to your server
unsubscribe() unsubscribe() → Promise<boolean> Deletes the subscription at the push service. true on success
subscribe.js
const reg = await navigator.serviceWorker.ready;
const sub = (await reg.pushManager.getSubscription())
  ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidKeyBytes }));
await fetch("/api/push/subscriptions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sub) });

JSON.stringify(sub) calls toJSON() for you. Server-side encryption, VAPID and push service responses are on The Web Push Protocol; the client flow is on Push Notifications.

PushEvent and PushMessageData

Member Type / signature Notes
PushEvent.data PushMessageData? null when the push carried no payload
PushMessageData.text() → USVString UTF-8 decoded payload
PushMessageData.json() → any Parses as JSON; throws SyntaxError on invalid JSON, so wrap it
PushMessageData.arrayBuffer() → ArrayBuffer Raw bytes
PushMessageData.bytes() → Uint8Array Chrome 132, Firefox 128, Safari 18
PushMessageData.blob() → Blob
PushEvent.notification Notification? Safari 18.4+: the proposed notification of a mutable Declarative Web Push message (then data is null)

All the PushMessageData methods are synchronous: the payload is already decrypted when the event fires.

sw.js (push handler)
self.addEventListener("push", (event) => {
  let msg = {};
  try { msg = event.data?.json() ?? {}; } catch { /* malformed payload: still notify */ }
  event.waitUntil(self.registration.showNotification(msg.title ?? "New activity", {
    body: msg.body, tag: msg.tag, data: { url: msg.url ?? "/" },
  }));
});

pushsubscriptionchange

pushsubscriptionchange fires in the worker when the push service expires, rotates or revokes a subscription. The event is a PushSubscriptionChangeEvent with oldSubscription and newSubscription (both PushSubscription?).

Engine Behavior
Chrome / Edge From Chrome 138, but only in one case: notification permission is granted again after it was revoked while a subscription existed. Chrome fires the event with oldSubscription and newSubscription both null
Firefox The event has fired since Firefox 44 (MDN marks that support as partial); the PushSubscriptionChangeEvent interface with both attributes shipped in Firefox 137
Safari Safari 16 on macOS. Not available on iOS and iPadOS (MDN: not supported), so Home Screen web apps must detect lost subscriptions themselves

newSubscription is often null, so the robust handler resubscribes with the options from oldSubscription (or from your stored VAPID key) and sends the result to your server, identifying the old record by its endpoint:

sw.js (resubscribe)
self.addEventListener("pushsubscriptionchange", (event) => {
  event.waitUntil((async () => {
    const options = event.oldSubscription?.options ?? { userVisibleOnly: true, applicationServerKey: await loadVapidKey() };
    const sub = event.newSubscription ?? (await self.registration.pushManager.subscribe(options));
    await fetch("/api/push/subscriptions", {
      method: "PUT", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ old: event.oldSubscription?.endpoint ?? null, subscription: sub }),
    });
  })());
});

A worker can only resubscribe silently when permission is still granted; otherwise subscribe() rejects with NotAllowedError and the page has to ask again. Server-side, also treat 404 Not Found and 410 Gone from the push service as "delete this subscription".

Declarative Web Push (Safari)

Safari 18.4 on iOS and iPadOS and Safari 18.5 on macOS accept push messages that describe the notification directly, so the browser can show it without running JavaScript. The page subscribes through window.pushManager (a PushManager on Window that needs no service worker) or through a registration's pushManager as usual.

declarative push payload
{
  "web_push": 8030,
  "notification": {
    "title": "New message from Ada",
    "body": "Are we still on for Friday?",
    "navigate": "https://example.com/inbox/42",
    "tag": "thread-42",
    "silent": false
  },
  "app_badge": 3,
  "mutable": true
}
Member Meaning
web_push Must be the number 8030; it marks the payload as declarative
notification.title, notification.navigate Required. navigate is the URL opened on click
notification.* Other NotificationOptions members such as body, tag, lang, dir, silent and data
app_badge Optional integer; Safari sets the app badge
mutable true lets an installed service worker's push handler modify the notification (event.notification holds the proposal). If the handler shows nothing, Safari shows the proposal

Other browsers see the same bytes as an ordinary JSON payload, so a service worker that parses this shape serves every engine. Details and the silent-push rules are on Web Push on iOS & Safari.

Notification and NotificationEvent

The Notifications API has two halves: the Notification object (permission, and non-persistent notifications from a page) and registration.showNotification() (persistent notifications owned by the service worker, which is what a PWA uses). Notification is exposed on W+Wk; on iOS and iPadOS it exists only in Home Screen web apps.

Notification member Signature / type Notes
permission static readonly NotificationPermission "default", "granted" or "denied". Read it synchronously before you show any opt-in UI
requestPermission() static requestPermission(optional NotificationPermissionCallback deprecatedCallback) → Promise<NotificationPermission> Window only. Firefox and Safari require a user gesture; Chrome shows a quieter UI or auto-blocks origins with low acceptance rates
maxActions static readonly unsigned long Maximum actions the platform displays (2 in Chromium; Firefox exposes it from 152); undefined where unsupported
constructor new Notification(title, optional NotificationOptions options = {}) Non-persistent notification. Throws TypeError inside service workers and in Chrome on Android; don't use it in a PWA
close() close() → undefined Dismisses the notification
click, show, error, close events Only for non-persistent notifications; persistent ones report to the worker instead
read-only attributes title, body, tag, icon, image, badge, lang, dir, data, actions, silent, requireInteraction, renotify, timestamp, vibrate, navigate Mirror the options that the engine supports

NotificationOptions for showNotification(title, options):

Option Type / default Support and behavior
body DOMString, "" All
tag DOMString, "" Replaces an existing notification with the same tag (Chromium, Firefox). Safari does not replace by tag
data any, null Structured-cloned; read it back in notificationclick as event.notification.data
icon USVString Chromium, Firefox. Safari always uses the app or site icon
badge USVString Chromium: monochrome status-bar icon on Android
image USVString Chromium: large image
actions sequence<NotificationAction>, [] Chromium 48+, Firefox 152+; not Safari. Extra entries beyond maxActions are ignored
requireInteraction boolean, false Chromium on desktop keeps the notification until the user acts; Firefox partial
renotify boolean, false Chromium. Re-alerts when replacing by tag; TypeError if tag is empty
silent boolean?, null No sound or vibration. TypeError when combined with vibrate. MDN lists Safari 16.6 on macOS and no iOS support; WebKit source suggests iOS plays the default sound unless silent is set, so test on a device
vibrate VibratePattern Chromium on Android
timestamp EpochTimeStamp Chromium: the time shown with the notification
lang, dir DOMString, ""; "auto" Direction "auto", "ltr" or "rtl"
navigate USVString Safari 18.4+: URL opened on click without running a notificationclick handler (the Declarative Web Push model)

NotificationAction is { action, title, icon?, navigate? }; action and title are required. showNotification() rejects with TypeError when permission isn't "granted", when the registration has no active worker, or for the invalid option combinations above.

NotificationEvent member Type Notes
notification Notification The clicked or closed notification, with its data
action DOMString The action id of the clicked button, or "" for a click on the body. Chromium 48+, Firefox 152+
waitUntil() inherited Required around clients.openWindow() / focus() so the worker lives until the window opens
sw.js (notificationclick)
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const url = new URL(event.notification.data?.url ?? "/", self.location.origin).href;
  event.waitUntil((async () => {
    if (event.action === "archive") return fetch("/api/archive", { method: "POST", body: event.notification.tag });
    const tabs = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
    const tab = tabs.find((c) => c.url === url);
    return tab ? tab.focus() : self.clients.openWindow(url);
  })());
});

registration.getNotifications({ tag }) returns the notifications still on screen, which is how you merge "3 new messages" into one. Everything about options, platforms and permission UX is on Notifications API.

Background Sync (SyncManager)

Chromium-only

SyncManager, PeriodicSyncManager and BackgroundFetchManager exist only in Chromium-based browsers (Chrome, Edge, Opera, Samsung Internet). Firefox and Safari have not implemented them. Pair each with a fallback that runs when the page is open.

registration.sync defers work until the browser believes it is online. Exposed on W+Wk; Chromium 49+.

Member Signature Returns / behavior
register() register(DOMString tag) → Promise<undefined> Registers a one-off sync for tag. Registering an existing pending tag merges with it. Rejects with InvalidStateError without an active worker, NotAllowedError when the permission is denied, and in Chromium InvalidAccessError when there is no top-level window of the origin or the tag is longer than 10,240 characters
getTags() getTags() → Promise<sequence<DOMString>> Tags that are pending, waiting to retry or firing
SyncEvent member Type Notes
tag DOMString The registered tag
lastChance boolean true on the final attempt. Chromium makes three attempts: immediately, about 5 minutes later and about 15 minutes after that

Chromium gives each sync event at most 3 minutes. If the waitUntil() promise rejects, the attempt counts as failed and is retried.

background sync one-liners
await (await navigator.serviceWorker.ready).sync.register("outbox");      // page, after writing to IndexedDB
self.addEventListener("sync", (e) => { if (e.tag === "outbox") e.waitUntil(flushOutbox({ final: e.lastChance })); });

Queue design and Workbox's BackgroundSyncPlugin are on Background Sync.

Periodic Background Sync (PeriodicSyncManager)

registration.periodicSync asks the browser to wake the worker at intervals. Chromium 80+, installed apps only. The periodic-background-sync permission is granted automatically when the origin has an installed app; otherwise register() rejects with NotAllowedError.

Member Signature Returns / behavior
register() register(DOMString tag, optional BackgroundSyncOptions options = {}) → Promise<undefined> options.minInterval is milliseconds ([EnforceRange] unsigned long long, default 0); a floor, never a schedule. Re-registering a tag updates its interval
getTags() getTags() → Promise<sequence<DOMString>> Registered tags
unregister() unregister(DOMString tag) → Promise<undefined> Removes the registration

The periodicsync event (PeriodicSyncEvent) has a single tag attribute. Chromium fires at most once every 12 hours per origin, scaled by site engagement (never for sites with no engagement), only on a network the device has used before, and gives the event at most 3 minutes.

periodic sync one-liners
const { state } = await navigator.permissions.query({ name: "periodic-background-sync" });
if (state === "granted") await reg.periodicSync.register("refresh-feed", { minInterval: 24 * 60 * 60 * 1000 });
self.addEventListener("periodicsync", (e) => { if (e.tag === "refresh-feed") e.waitUntil(refreshFeedCache()); });

Frequency rules and testing are on Periodic Background Sync.

Background Fetch (BackgroundFetchManager)

registration.backgroundFetch hands large downloads (or uploads) to the browser's download manager, which shows progress UI and keeps going when every tab is closed. Chromium 74+.

BackgroundFetchManager member Signature Returns / behavior
fetch() fetch(DOMString id, (RequestInfo or sequence<RequestInfo>) requests, optional BackgroundFetchOptions options = {}) Promise<BackgroundFetchRegistration> once the job is stored
get() get(DOMString id) Promise<BackgroundFetchRegistration?> for an active job
getIds() getIds() Promise<FrozenArray<DOMString>> of active jobs

BackgroundFetchOptions extends BackgroundFetchUIOptions: title (DOMString, default ""), icons (sequence<ImageResource>, default []) and downloadTotal (unsigned long long bytes, default 0 meaning unknown). downloadTotal is a hard cap: exceeding it fails the job with "download-total-exceeded".

fetch() rejects with TypeError for an empty request list, a no-cors request, a duplicate active id or no active worker; with QuotaExceededError when the job can't be stored; and with NotAllowedError when the background-fetch permission is denied. Three recent Chromium changes matter:

  • Chrome 149 restricts background fetches started from a service worker by default (the temporary enterprise policy RestrictBackgroundFetchFromServiceWorkerEnabled switches the restriction off). Start jobs from a page, in response to a user action.
  • CORS and Local Network Access: Chrome Platform Status lists enforcement for Chrome 154, after which cross-origin downloads need Access-Control-Allow-Origin like any fetch().
  • In November 2025 an intent to deprecate and remove Background Fetch was posted to blink-dev, citing usage below 0.00002% of page loads. The API still ships in Chrome 154, but treat it as an enhancement with a plain-fetch() fallback.
BackgroundFetchRegistration member Type / signature Notes
id DOMString
uploadTotal, uploaded, downloadTotal, downloaded unsigned long long Bytes; downloaded updates with progress events
result "", "success" or "failure" "" while running
failureReason "", "aborted", "bad-status", "fetch-error", "quota-exceeded" or "download-total-exceeded"
recordsAvailable boolean false after the terminal event settles
abort() → Promise<boolean>
match(), matchAll() (request?, CacheQueryOptions) → Promise<BackgroundFetchRecord…> Reject with InvalidStateError once records are gone
onprogress event handler Fires on the registration in pages and workers

A BackgroundFetchRecord has request and responseReady (Promise<Response>). In the worker, backgroundfetchsuccess and backgroundfetchfail are BackgroundFetchUpdateUIEvents with registration and updateUI({ title, icons }) (callable once, while the event is active); backgroundfetchabort and backgroundfetchclick are BackgroundFetchEvents with registration.

sw.js (store background fetch results)
self.addEventListener("backgroundfetchsuccess", (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open("episodes");
    const records = await event.registration.matchAll();
    await Promise.all(records.map(async (r) => cache.put(r.request, await r.responseReady)));
    await event.updateUI({ title: "Episode ready to play offline" });
  })());
});

Full lifecycle and fallbacks: Background Fetch.

StorageManager (navigator.storage)

navigator.storage is exposed on W+Wk in secure contexts. It covers quota, persistence and the origin private file system.

Member Signature Returns / behavior
estimate() estimate() → Promise<StorageEstimate> { usage, quota } in bytes; Chromium adds a non-standard usageDetails breakdown (caches, indexedDB, serviceWorkerRegistrations, fileSystem). Values are deliberately imprecise. To stop incognito detection, Chrome reports a predictable quota for sites without unlimited storage, usage + min(10 GiB, disk size rounded up to the next GiB), which is not the enforced limit. This is the default since Chrome 148 (rolled out from 144)
persisted() persisted() → Promise<boolean> Whether the origin's default bucket is persistent
persist() persist() → Promise<boolean> Window only. Chromium and Safari grant or deny silently from heuristics (installation, engagement, notification permission); Firefox prompts
getDirectory() getDirectory() → Promise<FileSystemDirectoryHandle> Root of the origin private file system. Rejects with SecurityError where storage is blocked (for example, some private browsing modes)
storage one-liners
const { usage, quota } = await navigator.storage.estimate();
const durable = (await navigator.storage.persisted()) || (await navigator.storage.persist());
const root = await navigator.storage.getDirectory();
const handle = await root.getFileHandle("drafts.json", { create: true });

Storage Buckets (Chromium 122+) let one origin split its data into independently evictable buckets: navigator.storageBuckets.open(name, { persisted, durability, quota, expires }) returns a StorageBucket with its own indexedDB, caches, getDirectory(), estimate(), persist(), persisted(), setExpires() and expires(); keys() and delete(name) manage them. Bucket names must be lowercase letters, digits, - and _, starting with a letter or digit.

Quotas per engine, eviction and the Safari seven-day rule are on Storage Quotas & Persistence; OPFS is on Origin Private File System.

cookieStore gives pages and service workers asynchronous access to cookies (not HttpOnly ones). Chrome 87, Firefox 140 and Safari 18.4 ship cookieStore; only Chromium and Firefox ship service worker change subscriptions.

Member Signature Notes
get() get(USVString name) or get(optional CookieStoreGetOptions options = {}) → Promise<CookieListItem?> Options: name, url (in a worker, must be in scope)
getAll() same arguments → Promise<CookieList>
set() set(USVString name, USVString value) or set(CookieInit options) → Promise<undefined> CookieInit: name, value, expires (ms), domain, path ("/"), sameSite ("strict" default), partitioned (false), and maxAge in seconds (Chrome 145, Safari 27)
delete() delete(name) or delete(CookieStoreDeleteOptions) → Promise<undefined> Options: name, domain, path, partitioned
change event (CookieChangeEvent) Window only: changed and deleted lists
registration.cookies.subscribe() subscribe(sequence<CookieStoreGetOptions>) → Promise<undefined> Worker receives cookiechange (ExtendableCookieChangeEvent). Also getSubscriptions() and unsubscribe()
sw.js (cookie one-liners)
const session = await self.cookieStore.get("session");             // no document.cookie in workers
await self.registration.cookies?.subscribe([{ name: "session" }]); // Chromium, Firefox 140+
self.addEventListener("cookiechange", (e) => e.waitUntil(onSessionChange(e.changed, e.deleted)));

Content Index

registration.index (a ContentIndex) lists offline-available content (articles, podcasts) in the browser's own UI; Chrome for Android shows the entries on its Downloads page. Chrome on Android 84+ only; treat it as an experiment.

Member Signature Notes
add() add(ContentDescription description) → Promise<undefined> { id, title, description, url, category, icons }. url must be in scope and the page must work offline; category is "", "homepage", "article", "video" or "audio"
delete() delete(DOMString id) → Promise<undefined>
getAll() getAll() → Promise<sequence<ContentDescription>>

When the user removes an entry in browser UI, the worker gets contentdelete (ContentIndexEvent with id); delete the cached content there.

Badging API

The Badging API sets a badge on the installed app's icon (taskbar, Dock, shelf or Home Screen). It lives on navigator in pages and in workers, including service workers, so a push handler can update it. Chrome and Edge 81+ on Windows, macOS and ChromeOS (since Chrome 152, installed apps on macOS need notification permission for the badge to appear); Safari 17+ on macOS (Dock web apps); iOS and iPadOS 16.4+ (Home Screen web apps with notification permission). Firefox doesn't implement it.

Member Signature Behavior
setAppBadge() setAppBadge(optional [EnforceRange] unsigned long long contents) → Promise<undefined> No argument: a "flag" badge (a dot in Chromium). A positive integer: that number (the OS may abbreviate large values). 0: clears. Negative numbers and NaN reject with TypeError; 3.7 becomes 3
clearAppBadge() clearAppBadge() → Promise<undefined> Same as setAppBadge(0)

Both reject with InvalidStateError when the document isn't fully active (for example, in the back/forward cache), with SecurityError from a frame that isn't same-origin with the top-level document, and with NotAllowedError when the browser requires notification permission and it isn't granted (the iOS model) or, in Chromium, inside a fenced frame. They resolve without effect where the platform has nowhere to draw a badge (Chrome on Linux). Chrome for Android does not expose the methods at all; Android draws its own notification dot on the app icon while a notification is showing. There is no getter: keep the count yourself.

badge one-liners
await navigator.setAppBadge?.(unreadCount);                          // page or service worker
await navigator.clearAppBadge?.();
self.addEventListener("push", (e) => e.waitUntil((async () => {
  let unread; try { unread = e.data?.json().unread; } catch { /* malformed payload */ }
  // Pass a number: setAppBadge() with no argument (or undefined) shows a flag badge instead.
  await Promise.all([showFromPush(e), Number.isInteger(unread) ? navigator.setAppBadge?.(unread) : null]);
})()));

Platform behavior, persistence and iOS rules are on Badging API.

Web Share API

navigator.share() opens the operating system's share sheet. Window only, secure context, controlled by the web-share Permissions Policy (default 'self').

Member Signature Returns / behavior
share() share(optional ShareData data = {}) → Promise<undefined> Resolves when the target accepted the data. Needs and consumes transient user activation
canShare() canShare(optional ShareData data = {}) → boolean false if nothing in data is shareable, a URL is invalid, files are unsupported, or the Permissions Policy blocks sharing. true does not guarantee that share() succeeds: the platform can still reject a file type

ShareData: title (USVString), text (USVString), url (USVString, resolved against the document URL; only http:/https: shareable) and files (sequence<File>). At least one known member must be present.

Rejection Cause
AbortError The user dismissed the share sheet (not an error to report), or the document became hidden
NotAllowedError No transient user activation, the Permissions Policy blocks it, or the file types are not allowed (Chromium's allowlist)
TypeError Nothing shareable, invalid URL, or files where file sharing isn't supported
InvalidStateError Another share is already in progress, or the document is not fully active
DataError The platform failed to start the target or transmit the data. Defined by the specification; engines may surface such failures as AbortError instead, so treat both as "share did not happen"
share one-liners
shareButton.addEventListener("click", async () => {
  const data = { title: document.title, url: location.href, files: preloadedFiles };
  try { await navigator.share(navigator.canShare?.(data) ? data : { title: data.title, url: data.url }); }
  catch (err) { if (err.name !== "AbortError") copyLinkFallback(); }
});

Support: Safari 12.1 (macOS) and 12.2 (iOS), files from Safari 14; Chrome on Android 61 (files 76); Chrome on Windows and ChromeOS 89, on macOS 128; Firefox for Android 79 (no files). Receiving shares is the manifest's share_target, covered on Web Share Target; sending is on Web Share API.

LaunchQueue and LaunchParams

window.launchQueue delivers launch data to an installed app: files opened through the manifest's file_handlers, and the target URL of launches handled by launch_handler. Chromium desktop only: Chrome 102 for files, Chrome 110 for targetURL.

Member Signature / type Notes
launchQueue.setConsumer() setConsumer(LaunchConsumer consumer) → undefined consumer is (LaunchParams params) => any. Launches queued before the call are delivered to the consumer as soon as it is set; later launches arrive directly
LaunchParams.targetURL USVString? The URL the launch was for. With client_mode: "focus-existing" the browser does not navigate, so you route to it yourself
LaunchParams.files FrozenArray<FileSystemHandle> File handles from the OS "Open with" action; [] for normal launches. Call getFile() or request write permission on them
launch.js
if ("launchQueue" in window) {
  launchQueue.setConsumer(async ({ targetURL, files }) => {
    if (files.length) return openDocuments(await Promise.all(files.map((h) => h.getFile())));
    if (targetURL) router.navigate(new URL(targetURL).pathname); // focus-existing launches
  });
}

Set the consumer early (during module evaluation) so the first launch is not delayed. See File Handling and Protocol Handlers & Launch Handling.

Window Controls Overlay

With "display_override": ["window-controls-overlay"] in the manifest, Chromium desktop apps (Chrome and Edge 105+) can extend content into the title bar. navigator.windowControlsOverlay reports the free area.

Member Signature / type Notes
visible readonly boolean true while the overlay is active (the user can toggle it)
getTitlebarAreaRect() → DOMRect The title bar area not covered by the window controls, in CSS pixels. All zeros when not visible
ongeometrychange WindowControlsOverlayGeometryChangeEvent Fires on resize and toggle, with titlebarAreaRect and visible

CSS gets the same rectangle as env(titlebar-area-x), env(titlebar-area-y), env(titlebar-area-width) and env(titlebar-area-height), which are undefined while the overlay is off, so the env() fallback applies. Mark the draggable area with app-region: drag and every control inside it with app-region: no-drag. Chromium has long supported the prefixed -webkit-app-region (the only form MDN's compatibility data records), so declare both.

wco one-liners
const wco = navigator.windowControlsOverlay;
wco?.addEventListener("geometrychange", ({ titlebarAreaRect, visible }) => layoutHeader(titlebarAreaRect, visible));
document.body.classList.toggle("wco", wco?.visible ?? false);

Layout recipes are on Window Controls Overlay.

BeforeInstallPromptEvent and appinstalled

Chromium-only, non-standard

beforeinstallprompt is implemented only by Chromium-based browsers and is not part of the manifest specification (it was removed from it and lives in a separate incubation). Firefox and Safari never fire it. Build install UI that works without it.

Chromium fires beforeinstallprompt on window when the page meets the installability criteria and the app isn't installed. Call preventDefault() to suppress the automatic mini-infobar on Android and keep the event for your own button.

Member Signature / type Notes
prompt() prompt() → Promise<{ outcome, platform }> Shows the install dialog. Needs transient user activation and consumes it (NotAllowedError otherwise). One dialog per event: a second call rejects. InvalidStateError on a constructed or disconnected event
userChoice readonly Promise<{ outcome, platform }> outcome is "accepted" or "dismissed". Resolves after the user decides, whoever triggered the dialog
platforms readonly FrozenArray<DOMString> Non-standard; ["web"] in practice
preventDefault() inherited Suppresses the automatic prompt so you can defer it

appinstalled is a plain Event fired on window after any successful install, including installs from the browser menu or address bar (Chrome 64 desktop, 57 Android). Use it for analytics and to hide your install button.

install-button.js
let deferred = null;
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferred = e; installButton.hidden = false; });
installButton.addEventListener("click", async () => {
  if (!deferred) return;
  const { outcome } = await deferred.prompt(); // synchronous from the click: keeps user activation
  deferred = null; installButton.hidden = true;
  analytics.track("install_prompt", { outcome });
});
addEventListener("appinstalled", () => analytics.track("app_installed"));

Full patterns, including iOS instructions, are on Install Prompts & Custom UI.

getInstalledRelatedApps()

navigator.getInstalledRelatedApps() tells a page whether the user has installed one of the apps listed in its manifest's related_applications. Chromium only; secure contexts and the top-level document only (in an iframe it rejects with InvalidStateError).

Member Signature Returns
getInstalledRelatedApps() getInstalledRelatedApps() → Promise<sequence<RelatedApplication>> Installed apps that are both listed in related_applications and verify the relationship back. [] when none, when the manifest has none, or in Incognito

RelatedApplication is { platform, url, id, version }. Supported platforms and the back-link each needs:

platform Detects Verification Chromium version
"play" Android app from Google Play (by id), including a TWA Digital Asset Links statement in the Android app (delegate_permission/common.handle_all_urls for your site) Android 80
"webapp" Your installed PWA (by manifest url) Same scope: the manifest lists itself. Other scope or origin (Android only): assetlinks.json on the PWA's origin with delegate_permission/common.query_webapk Android 84; desktop 140 (same scope, absolute id)
"windows" UWP/Windows app (id is <PackageFamilyName>!App) App URI handler in the app manifest plus a windows-app-web-link file on your site Windows 85

Chrome only considers the first three entries of related_applications, and min_version and fingerprints are not implemented for this API.

related-apps.js
const apps = (await navigator.getInstalledRelatedApps?.()) ?? [];
const hasPwa = apps.some((a) => a.platform === "webapp");
installButton.hidden = hasPwa; // the app is already installed on this device

See Detecting Installed Apps.

Web Install API (navigator.install())

Experimental

navigator.install() and the <install> element are not enabled by default in any stable browser as of September 2026. navigator.install() ran as an origin trial in Chrome and Edge desktop from version 143 to 148 (extended through 150) and the <install> element from 148 to 153 (that trial has ended); outside a trial they need chrome://flags. An Intent to Ship posted in September 2026 targets desktop Chrome 156 for navigator.install() (Chrome Platform Status also lists <install>, whose attributes are manifest and manifestId). There is no Android implementation. WebKit opposes the proposal and Gecko has not signaled a position.

Form Signature Behavior
Current document navigator.install() → Promise<WebInstallResult> Installs the current page's app. Its manifest must declare an id, otherwise the call rejects with DataError
Another app navigator.install({ manifest, manifestId }) → Promise<WebInstallResult> manifest is the manifest URL. manifestId may be omitted only when that manifest has an id, and must match it when given

The promise resolves with an empty WebInstallResult dictionary (reserved for future fields). Rejections from the explainer: NotAllowedError (no transient user activation, or blocked by the web-app-installation Permissions Policy), InvalidStateError (called outside the top-level frame or in a sandboxed context), TypeError (invalid arguments or URL scheme), AbortError (the user canceled the dialog), DataError (manifest fetch or parse failure, missing id, or manifestId mismatch) and NotFoundError (no document). The shape changed during the trials, so feature-detect with "install" in navigator and fall back to beforeinstallprompt. See Install Prompts & Custom UI.

Display mode and standalone detection

Installed apps run in a display mode chosen from the manifest's display_override and display. Read it with media queries, never with user-agent sniffing.

Media query value Matches when Support
(display-mode: browser) A normal browser tab All
(display-mode: minimal-ui) App window with minimal browser UI Chromium, Firefox for Android 116+, Firefox 143+ for web apps pinned to the Windows taskbar. Never matches in Safari
(display-mode: standalone) App window with no browser UI Chromium, Safari 13+, Firefox for Android 116+
(display-mode: fullscreen) App fills the screen (or the Fullscreen API is active in some engines) Chrome desktop 47+, Firefox for Android 116+; partial in Firefox desktop and Safari; not Chrome for Android
(display-mode: window-controls-overlay) Title bar overlay active Chromium desktop 105+
(display-mode: picture-in-picture) The document is in a Document Picture-in-Picture window Chrome 123, Firefox 151

navigator.standalone (a non-standard boolean) is true in iOS and iPadOS Home Screen web apps and is the most reliable check there: check it first, because a Home Screen web app whose manifest says "display": "standalone" matches (display-mode: fullscreen) (WebKit bug 264218) and one without a manifest reports browser. Since Safari 17 the property also exists on macOS (false in tabs, true in Dock web apps), so its presence does not imply iOS; combine it with navigator.maxTouchPoints > 0 when you need the platform. Listen for changes with matchMedia(q).addEventListener("change", …): a user can move a Chromium app between a window and a tab.

display-mode.js
const isInstalledContext = () =>
  ["standalone", "minimal-ui", "fullscreen", "window-controls-overlay"]
    .some((m) => matchMedia(`(display-mode: ${m})`).matches) || navigator.standalone === true;
matchMedia("(display-mode: standalone)").addEventListener("change", (e) => report(e.matches ? "app" : "tab"));

Manifest fields are summarized on the Manifest Cheat Sheet and explained on Display Modes.

Permission names for PWA features

navigator.permissions.query({ name }) reads a permission's state ("granted", "denied" or "prompt") without prompting, and its PermissionStatus fires change. Names relevant to PWAs:

Permission name Governs Query support
"notifications" Notification.requestPermission(), showNotification() Chrome 43, Firefox 46, Safari 16.4
"push" pushManager.subscribe(). Chrome requires { name: "push", userVisibleOnly: true } Chrome 43, Firefox 46, Safari 17
"persistent-storage" navigator.storage.persist() Chrome 71, Firefox 53
"background-sync" sync.register() Chromium 62
"periodic-background-sync" periodicSync.register() Chromium 80
"screen-wake-lock" navigator.wakeLock.request() Chrome 84, Firefox 126, Safari 16.4
"window-management" Multi-screen window placement Chromium 111 desktop
"web-app-installation" Cross-origin navigator.install() (experimental) Chromium, behind a flag

Querying a name the engine doesn't know throws TypeError, so wrap query() in try/catch. Permission UX and Permissions Policy are on Permissions.

Other capability APIs PWAs use

These APIs are not PWA-specific, but installed apps lean on them. Each has a full page in the Device & OS Integration section.

API Entry point Support summary Page
Protocol handlers navigator.registerProtocolHandler(scheme, url); manifest protocol_handlers Chromium desktop and Firefox desktop for the method; Chromium desktop 96+ for the manifest member Protocol Handlers & Launch Handling
File System Access showOpenFilePicker(), showSaveFilePicker(), showDirectoryPicker() Chromium desktop 86, Chrome for Android 132 (all three pickers, via the Android system document picker). OPFS is everywhere File System Access
Screen Wake Lock navigator.wakeLock.request("screen") → WakeLockSentinel Chrome 84, Firefox 126, Safari 16.4 (macOS and iOS tabs; iOS Home Screen web apps from 18.4) Media & System APIs
Media Session navigator.mediaSession.metadata, setActionHandler() Chrome 73 desktop and 57 Android, Firefox 82, Safari 15 Media & System APIs
Document Picture-in-Picture documentPictureInPicture.requestWindow() Chrome 116 desktop, Firefox 151 desktop Media & System APIs
Web Locks navigator.locks.request(name, callback) Chrome 69, Firefox 96, Safari 15.4; available in service workers Offline-First Data & Sync
BroadcastChannel new BroadcastChannel(name) Chrome 54, Firefox 38, Safari 15.4 Messaging & the Clients API
Payment Request / Payment Handler new PaymentRequest(); registration.paymentManager Payment Request everywhere; Payment Handler Chromium only Payments
WebAuthn / passkeys navigator.credentials.create() / get() All engines Authentication & Passkeys

Exceptions quick reference

Almost every PWA API reports failure as a rejected promise holding a DOMException (or a plain TypeError). Branch on err.name, never on the message text, which differs between engines.

err.name Typical PWA causes
SecurityError register() from an insecure origin, cross-origin script, wrong MIME type, or a scope outside the allowed maximum; OPFS or storage access blocked
TypeError Bad arguments; register() fetch failure, non-OK status or redirect; cache.put() of a non-GET or 206; addAll() with a non-OK response; showNotification() without permission or with renotify/silent conflicts; navigate() on an uncontrolled client; Background Fetch validation; share() with nothing shareable
InvalidStateError respondWith() called late or twice; waitUntil() after the event ended; update() or preload changes without an active worker; subscribe() with a different VAPID key; a second concurrent share(); prompt() on a disconnected event; Background Fetch records gone
NotAllowedError Permission denied; missing transient user activation (share(), prompt(), navigator.install(), Safari/Firefox subscribe()); userVisibleOnly: false (Chromium, Safari); Permissions Policy; Periodic Sync without an installed app; Chrome Incognito push
InvalidAccessError openWindow()/focus() outside notificationclick; invalid P-256 VAPID key; Chromium sync.register() with no window or an oversized tag
AbortError User dismissed the share sheet or install dialog; push service failure in Chrome; responseReady of an aborted Background Fetch
QuotaExceededError Cache, IndexedDB, OPFS or Background Fetch writes beyond the quota
DataError share() target failure; navigator.install() manifest problems
NotSupportedError Push service requires an applicationServerKey and none was given
InvalidCharacterError applicationServerKey string isn't base64url
NetworkError importScripts() of an uncached script after installation

A defensive wrapper for optional APIs keeps the call sites short:

try-feature.js
/**
 * Call an optional PWA API and classify its failure.
 * Returns { ok: true, value } or { ok: false, reason } where reason is
 * "unsupported", "cancelled", "denied" or "failed".
 */
export async function tryFeature(fn) {
  try {
    return { ok: true, value: await fn() };
  } catch (err) {
    if (err instanceof ReferenceError || err?.name === "NotSupportedError") return { ok: false, reason: "unsupported", err };
    if (err?.name === "AbortError") return { ok: false, reason: "cancelled", err };
    if (err?.name === "NotAllowedError" || err?.name === "SecurityError") return { ok: false, reason: "denied", err };
    return { ok: false, reason: "failed", err };
  }
}

// Usage: const result = await tryFeature(() => navigator.share({ url: location.href }));

Further reading

On this site

External references


  1. Apple shipped Declarative Web Push on macOS in Safari 18.5; MDN's compatibility data lists window.pushManager on macOS from 18.4. ↩