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 toWindowand workers.Clients,FetchEventand the other event types exist only inside the service worker. - The core works in all current engines: registration, lifecycle,
fetchinterception, 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),pushsubscriptionchangein Chrome 138, module service workers in Firefox 147, notificationactionsin 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
displayvalue is required. Safari tabs on iOS have noNotificationobject and noPushManager. 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 thedom.webshare.enabledpreference; 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.enabledpreference and is off by default. - Firefox
display-mode: standaloneparses from Firefox 57 but never matches on desktop, which has no standalone app window (the Windows taskbar web apps of Firefox 143+ are covered underminimal-uibelow); Firefox for Android matches it from version 116. - iOS
display-modeis partial: in a Home Screen web app whose manifest says"display": "standalone",(display-mode: standalone)isfalseand(display-mode: fullscreen)istrue(WebKit bug 264218), andminimal-uinever matches. A site added without a manifest reportsbrowser. Checknavigator.standalone === truefirst 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:
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 |
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.
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 throwsInvalidStateError. You may call it asynchronously as long as an earlierwaitUntil()promise is still pending (asynchronouswaitUntil(): Chrome 60, Firefox 53, Safari 11.1). - In
install, a rejected promise makes the new worker redundant. Inactivate, a rejection is ignored: the worker still becomes activated. - In
push, the promise must includeshowNotification()when you subscribed withuserVisibleOnly: 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
syncandperiodicsyncevents at most 3 minutes, and givespusha 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.
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) |
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.
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.
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.
NavigationPreloadManager¶
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.
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 |
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.
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:
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.
{
"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 |
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.
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.
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
RestrictBackgroundFetchFromServiceWorkerEnabledswitches 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-Originlike anyfetch(). - 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.
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) |
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.
Cookie Store API¶
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() |
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.
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" |
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 |
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.
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.
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.
const apps = (await navigator.getInstalledRelatedApps?.()) ?? [];
const hasPwa = apps.some((a) => a.platform === "webapp");
installButton.hidden = hasPwa; // the app is already installed on this device
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.
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:
/**
* 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
- Core Building Blocks
- Lifecycle
- Handling Fetch Events
- Cache Storage API
- Push Notifications
- Manifest Cheat Sheet
- Glossary
- Resources & Specifications
External references
- Service Workers specification (W3C) and the editor's draft
- Push API specification
- Notifications API Standard (WHATWG)
- Badging API specification
- Web Share API specification
- MDN: Service Worker API
- MDN: Progressive web apps
- MDN browser-compat-data
-
Apple shipped Declarative Web Push on macOS in Safari 18.5; MDN's compatibility data lists
window.pushManageron macOS from 18.4. ↩