Service Worker Registration and Scope¶
Registering a service worker with navigator.serviceWorker.register() tells the browser to fetch a script, validate it, install it, and bind it to a scope: a URL prefix whose pages the worker will control on future navigations. Almost every "my service worker doesn't work" report traces back to registration: a script served with the wrong MIME type, a scope wider than the script's directory, a CDN redirect, a Content Security Policy, a sandboxed iframe, or a page that simply isn't inside the scope it thinks it is. This page covers the registration API down to the algorithm steps, every rule that decides which registration controls which page, and every error you can get back, with production-ready code.
Key takeaways
register(scriptURL, { scope, type, updateViaCache })resolves as soon as installation starts. It does not wait for install or activation, and it still resolves when theinstallhandler later fails.- The default scope is the script's directory (
./resolved against the script URL). A wider scope needs theService-Worker-Allowedresponse header on the script. - Scope matching is a plain string prefix match on the full URL, and the longest matching scope wins.
/appalso matches/apple, so always end scopes with/. - The script must be same-origin, served over HTTPS (or localhost), with a JavaScript MIME type, a
2xxstatus and no redirects. SPA fallbacks that returnindex.htmlfor a missingsw.jsare the most common failure. - Calling
register()again with the same URL is a cheap no-op. It does not trigger an update check; navigations do. See Updating Service Workers. - Third-party iframes get partitioned registrations in Safari, Firefox and Chrome, keyed by the top-level site.
- Classify rejections by
error.name:SecurityErrormeans a policy violation,TypeErrormeans a fetch, URL or evaluation failure, andInvalidStateErrormeans the document is not in a usable state.
The registration API at a glance¶
The registration surface lives on navigator.serviceWorker, a ServiceWorkerContainer. This is the relevant IDL from the Service Workers specification, trimmed to what matters for registration:
partial interface Navigator {
[SecureContext, SameObject] readonly attribute ServiceWorkerContainer serviceWorker;
};
[SecureContext, Exposed=(Window,Worker)]
interface ServiceWorkerContainer : EventTarget {
readonly attribute ServiceWorker? controller;
readonly attribute Promise<ServiceWorkerRegistration> ready;
[NewObject] Promise<ServiceWorkerRegistration> register(
(TrustedScriptURL or USVString) scriptURL,
optional RegistrationOptions options = {});
[NewObject] Promise<(ServiceWorkerRegistration or undefined)> getRegistration(
optional USVString clientURL = "");
[NewObject] Promise<FrozenArray<ServiceWorkerRegistration>> getRegistrations();
undefined startMessages();
attribute EventHandler oncontrollerchange;
attribute EventHandler onmessage;
attribute EventHandler onmessageerror;
};
dictionary RegistrationOptions {
USVString scope;
WorkerType type = "classic";
ServiceWorkerUpdateViaCache updateViaCache = "imports";
};
enum ServiceWorkerUpdateViaCache { "imports", "all", "none" };
The ServiceWorkerRegistration object that register() resolves with exposes installing, waiting and active (each a ServiceWorker or null), scope (the serialized scope URL), updateViaCache, navigationPreload, the methods update() and unregister(), and the updatefound event. Other specifications hang more members off it, such as pushManager, sync, periodicSync, backgroundFetch, cookies, paymentManager and showNotification(), each with its own support story.
Registration options¶
| Option | Type and default | What it controls |
|---|---|---|
scope | URL string, default "./" resolved against the script URL | The URL prefix this registration controls. If you pass it, it is resolved against the document's base URL, not the script URL. It must not be wider than the maximum scope (see below). |
type | "classic" (default) or "module" | Whether the script is parsed as a classic script (importScripts() allowed) or an ES module (static import allowed, importScripts() throws). |
updateViaCache | "imports" (default), "all", "none" | Whether the HTTP cache may satisfy update-check fetches for the main script and imported scripts. Covered in depth in Updating Service Workers. |
What register() actually does¶
register() does very little synchronously. It converts the URL (applying Trusted Types when enforced), resolves scriptURL and scope against the document's API base URL, and hands everything to the Start Register algorithm, which runs cheap validations and then queues a register job on a per-scope job queue. Jobs for the same scope run strictly one at a time. The spec also notes that the browser delays running a register or update job until after DOMContentLoaded has been dispatched to the document that started it, so calling register() in the <head> gains you nothing.
sequenceDiagram
participant Page
participant UA as Browser (job queue)
participant Net as Network
participant SW as New worker
Page->>UA: register("/sw.js", options)
Note over UA: Start Register resolves URLs, checks schemes, rejects encoded slashes
UA->>UA: Register job: check origins, look up existing registration
alt Same script URL, type and updateViaCache
UA-->>Page: resolve with existing registration (no fetch)
else New or changed
UA->>Net: GET /sw.js with Service-Worker header, redirects are errors
Net-->>UA: 200, text/javascript, optional Service-Worker-Allowed
Note over UA: MIME check, max scope check, byte comparison
UA->>SW: Run script (top-level evaluation)
UA-->>Page: resolve registration, fire updatefound
UA->>SW: install event, then waiting or activate
end The job-level steps, in order, are:
- Register: reject with
SecurityErrorif the script URL's origin is not potentially trustworthy, or if the script or scope origin differs from the registering page's origin. - Look up an existing registration for the exact scope (in this storage partition). If one exists and its newest worker has the same script URL, same type, and the registration has the same
updateViaCache, resolve with it immediately and stop. No network request happens. - Otherwise create the registration if needed and run Update: fetch the script, check its MIME type, enforce the maximum scope, compare bytes with the newest worker, evaluate it, and run Install.
- Install sets the registration's
installingworker, resolves theregister()promise, firesupdatefound, then dispatches theinstallevent.
Step 4 is why register() resolving tells you nothing about whether installation succeeded. If event.waitUntil() in the install handler rejects, the worker goes straight to redundant, and if it was the only worker, the registration is removed again. Your register() promise has long since resolved. Watch the worker's statechange events if you need to know the outcome.
Resolution semantics by situation¶
| Situation | Network fetch? | register() result | What you observe afterwards |
|---|---|---|---|
| First registration, script valid, install succeeds | Yes | Resolves with installing set | statechange to installed, then activating, then activated. ready resolves. |
First registration, install handler rejects | Yes | Resolves | Worker becomes redundant, registration is removed. ready never resolves. |
| First registration, script throws during top-level evaluation | Yes | Rejects with TypeError | Registration is removed. |
Same URL, type and updateViaCache as the newest worker | No | Resolves with the existing registration | Nothing. This is not an update check. |
| Same scope, different script URL | Yes | Resolves (if valid) | A new worker installs even if the bytes are identical, because the URL changed. |
Same URL, different updateViaCache | Yes | Resolves | Byte-identical: the mode is updated in place. Changed: a new worker installs. |
Two tabs call register() concurrently with equivalent arguments | Once | Both resolve with the same registration | Equivalent jobs are coalesced onto the job already in the queue. |
Feature detection and secure contexts¶
navigator.serviceWorker is marked [SecureContext], so on an insecure page the property does not exist at all: 'serviceWorker' in navigator is false. That makes the classic check correct but incomplete, because there are contexts where the property exists and still cannot be used:
- Sandboxed iframes without
allow-same-originhave an opaque origin. In Chromium, merely readingnavigator.serviceWorkerthrows aSecurityErrorwith the message "Service worker is disabled because the context is sandboxed and lacks the 'allow-same-origin' flag." Other opaque-origin documents get "Access to service workers is denied in this document origin." file:pages: the property may exist, butregister()rejects with aTypeErrorbecause onlyhttp:andhttps:are allowed (Chromium's message reads "The URL protocol of the current origin (…) is not supported.").- Firefox private windows exposed
navigator.serviceWorkerasundefinedbefore Firefox 139, according to MDN's compatibility data. From 139 on, service workers work in private windows. - Users who block site data: in Chromium, if cookies and site data are blocked for the site,
register()rejects with aNotSupportedErrorwhose message ends in "The user denied permission to use Service Worker." - Embedded WebViews: MDN's compatibility data lists
register()as unsupported in iOSWKWebView, which many in-app browsers use.
A robust detection helper therefore guards the property access itself:
/**
* Returns the ServiceWorkerContainer if this context can use service workers,
* otherwise null. Never throws.
*/
export function getServiceWorkerContainer() {
// Insecure contexts: the attribute is [SecureContext], so it is absent.
if (!window.isSecureContext) return null;
try {
// Chromium throws a SecurityError on *access* in opaque-origin documents
// (for example sandboxed iframes without allow-same-origin).
const container = navigator.serviceWorker;
return container ?? null; // undefined in older Firefox private windows
} catch {
return null;
}
}
Secure contexts and local development¶
Service workers require a secure context. In practice that means HTTPS in production. For development, browsers treat http://localhost, http://127.0.0.0/8 and http://[::1] as potentially trustworthy, which the spec explicitly allows. Testing on a phone over your LAN IP (http://192.168.1.20:5173) is not a secure context. Your options are:
- Use a tunnel or reverse proxy that gives you a real HTTPS hostname.
- Use a locally trusted certificate (for example one generated with
mkcert) and install its root on the test device. - In Chromium, add the origin to
chrome://flags/#unsafely-treat-insecure-origin-as-secureon a test device only.
A certificate error is also fatal: even if a user clicks through an interstitial, Chromium refuses to use the service worker script ("An SSL certificate error occurred when fetching the script."). Chromium's source maps certificate errors on the main script to its security status, so a first registration rejects with SecurityError, while the spec, which only sees a network error, would call for a TypeError. The check is skipped only when DevTools has been told to ignore certificate errors or the browser was launched with --ignore-certificate-errors, which is why a registration can work under automated testing and fail for real users on the same self-signed certificate.
When to register¶
The registration call is cheap. What it kicks off on a first visit is not: the browser downloads and evaluates the worker, and the install handler usually precaches an app shell, which can be dozens of requests. If that happens while the page is still fetching its own critical CSS, fonts, images and scripts, the two compete for bandwidth, connections and main-thread time on low-end devices.
The standard advice is to register after the window load event:
import { getServiceWorkerContainer } from "./sw-support.js";
function registerServiceWorker(container) {
container
.register("/sw.js", { scope: "/" })
.catch((error) => console.warn("Service worker registration failed:", error));
}
const container = getServiceWorkerContainer();
if (container) {
// If this module runs after load (e.g. it was lazy-loaded), register now.
if (document.readyState === "complete") {
registerServiceWorker(container);
} else {
window.addEventListener("load", () => registerServiceWorker(container), {
once: true,
});
}
}
Some nuances that change the calculation:
- Returning visitors are unaffected. When a user comes back, the page is already controlled, and the navigation itself triggers the update check. The
register()call is then a no-op that resolves without touching the network (see the resolution table above). Delaying it costs nothing. - The first page view is never controlled anyway. A newly installed worker controls nothing until the next navigation unless it calls
clients.claim(). Registering early rarely makes the first visit faster; it only makes the second visit work offline slightly sooner. - Idle-time registration. If your
loadevent fires early but the app keeps hydrating afterwards, you can defer further withrequestIdleCallback(). Safari does not ship it (MDN lists it only behind a flag in Safari Technology Preview), so feature-detect it and fall back tosetTimeout(). - Prerendered pages. Chromium defers
register()calls made by a prerendered page until the page is activated, so speculative prerenders don't install workers the user never sees. - Workbox (
workbox-window) waits forloadby default. Itsregister({ immediate: true })option skips the wait, which the library itself labels "not recommended".
If the app shell is large, the bigger lever is what the install handler does, not when you call register(). See Precaching & Runtime Caching and Loading Performance.
How scope works¶
A registration's scope URL decides which pages it controls. Control is decided at navigation time: when the browser creates a new document (or dedicated/shared worker), it looks for the registration whose scope matches the URL being loaded, and if that registration has an active worker, the new client is controlled by it for its whole lifetime.
Default scope and URL resolution¶
Three details of URL resolution trip people up:
- Both
scriptURLandscoperesolve against the document's base URL, not the page's directory and not each other. A<base href="/static/">element in the page therefore changes whereregister("sw.js")points. - The default scope is
./resolved against the script URL, which is the script's directory. So the script's location, not the page's, determines the default. - Fragments are stripped from both URLs, and a path containing
%2for%5c(encoded/or\, any case) is rejected with aTypeError. The rule exists because servers disagree about whether an encoded slash is a path separator.
| Page URL | Call | Script URL | Resulting scope |
|---|---|---|---|
https://example.com/ | register("/sw.js") | /sw.js | https://example.com/ |
https://example.com/blog/post | register("sw.js") | /blog/sw.js | https://example.com/blog/ |
https://example.com/blog/post | register("/sw.js") | /sw.js | https://example.com/ |
https://example.com/app/ | register("/app/sw.js", { scope: "./" }) | /app/sw.js | https://example.com/app/ (scope resolved against the page) |
https://example.com/ | register("/js/sw.js") | /js/sw.js | https://example.com/js/, which controls almost nothing |
https://example.com/ with <base href="/static/"> | register("sw.js") | /static/sw.js | https://example.com/static/ |
The fifth row is a classic bug: the build tool emits the worker into /js/, the registration "succeeds", but navigator.serviceWorker.controller stays null on every page and navigator.serviceWorker.ready never resolves, because no page lives under /js/.
The maximum scope rule¶
Unless the server says otherwise, a worker may only control URLs at or below its own directory. During the update fetch the browser computes a max scope string: the path of ./ resolved against the script URL, always ending in /. The requested scope's path must start with it:
script /sw.js max scope "/" scope "/" OK
script /app/sw.js max scope "/app/" scope "/app/" OK
script /app/sw.js max scope "/app/" scope "/app/admin/" OK (narrower is always allowed)
script /js/sw.js max scope "/js/" scope "/" SecurityError
script /app/sw.js max scope "/app/" scope "/app" SecurityError ("/app" does not start with "/app/")
The last row surprises people: a scope of /app without the trailing slash is wider than /app/ (it would also match /application), so it fails the prefix check against /app/.
In Chromium the rejection reads:
Failed to register a ServiceWorker for scope ('https://example.com/') with script
('https://example.com/js/sw.js'): The path of the provided scope ('/') is not under the
max scope allowed ('/js/'). Adjust the scope, move the Service Worker script, or use the
Service-Worker-Allowed HTTP header to allow the scope.
The path restriction is not a security boundary
The spec is explicit that only origins are security boundaries. The max-scope rule gives sites that host several users' content under paths (/~alice/, /~bob/) some protection against one user's script claiming the whole origin, but any script served from the same origin can read the same cookies, Cache Storage and IndexedDB, and can register its own worker for its own directory. If untrusted parties can upload JavaScript to your origin, they can install a service worker: isolate user content on a separate origin.
The Service-Worker-Allowed header¶
If your script has to live somewhere else (a build pipeline that emits into /assets/, a framework that serves it from /_next/static/, a CMS that can only host files under /media/), send the Service-Worker-Allowed response header on the script response to raise the maximum scope:
HTTP/2 200
content-type: text/javascript; charset=utf-8
cache-control: no-cache
service-worker-allowed: /
Rules for the header value:
- It is a URL, parsed relative to the script URL.
/means the origin root;../from/assets/sw.jsalso means/. - The parsed URL must be same-origin with the script. A cross-origin value makes the registration fail (Chromium: "A cross-origin Service-Worker-Allowed header value ('…') was received when fetching the script.").
- An unparseable value makes the fetch fail (Chromium: "An invalid Service-Worker-Allowed header value ('…') was received when fetching the script.").
- It only raises the ceiling. You still have to pass
{ scope: "/" }toregister(), because the default scope remains the script's directory. - The header is checked on every update fetch, not just the first. If a later deploy drops it, the update fails with a
SecurityErrorand the old worker keeps running.
Server configurations for serving a worker from a subdirectory with a root scope:
location = /assets/sw.js {
# add_header in a location block REPLACES any add_header directives
# inherited from the server block, so repeat security headers here.
add_header Service-Worker-Allowed "/" always;
add_header Cache-Control "no-cache" always;
# The worker's own CSP: connect-src must cover every origin it fetches.
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com" always;
types { text/javascript js; }
try_files $uri =404; # never fall back to index.html for the worker
}
import express from "express";
import path from "node:path";
const app = express();
app.get("/assets/sw.js", (req, res) => {
res.set({
"Service-Worker-Allowed": "/",
"Cache-Control": "no-cache",
"Content-Type": "text/javascript; charset=utf-8",
});
res.sendFile(path.resolve("dist/assets/sw.js"));
});
If you control the build, the simpler fix is usually to emit the worker at the root (/sw.js) and avoid the header entirely: one fewer header that a future CDN migration can silently drop.
Scope matching is string prefix matching¶
When a navigation happens, the browser runs Match Service Worker Registration: it serializes the target URL and picks, among all registration scopes in the same storage partition, the longest scope string that the URL starts with. The spec calls out that this is "prefix-based rather than path-structural". Chromium's implementation is literally url.spec().starts_with(scope.spec()).
Consequences:
- A scope of
https://example.com/appmatcheshttps://example.com/app/,https://example.com/app.html,https://example.com/apple-pieandhttps://example.com/app?x=1. End scopes with a slash unless you really mean a string prefix. - The query string is part of the comparison. A scope of
/search?lang=enonly matches URLs starting with exactly that; it is legal but almost never what you want. - Origin mixing cannot happen: serialized HTTP(S) URLs always contain the
/after the host, sohttps://example.comcan never be a prefix ofhttps://example.com.evil.test/. - Matching happens only at client creation. An in-page
history.pushState()to a URL outside the scope does not release control, and a page loaded before a registration existed is not picked up withoutclients.claim().
flowchart TD
A["Navigation to https://example.com/app/admin/users"] --> B["Collect scopes in this storage partition"]
B --> C{"Which scopes are string prefixes of the URL?"}
C --> D["https://example.com/"]
C --> E["https://example.com/app/"]
C --> F["https://example.com/app/admin/"]
D --> G["Pick the longest: /app/admin/"]
E --> G
F --> G
G --> H{"Has an active worker?"}
H -- yes --> I["Page is controlled by the /app/admin/ worker"]
H -- no --> J["Page is uncontrolled. No fallback to shorter scopes"] Note the last box: if the longest matching registration has no active worker yet (its first install is still running, or it failed), the page is uncontrolled. The browser does not fall back to the next-longest scope.
Multiple registrations on one origin¶
An origin can hold any number of registrations as long as their scope URLs differ. Registering again for a scope that already has a registration updates that registration rather than creating a second one, and if the script URL differs, the new script replaces the old one through the normal update lifecycle.
Nested scopes are legal and useful when different sections have different offline needs:
| Registration | Script | Controls |
|---|---|---|
https://example.com/ | /sw.js | Marketing pages, docs, anything not matched below |
https://example.com/app/ | /app/sw.js | The application shell and its routes |
https://example.com/app/admin/ | /app/admin/sw.js | The admin area, with different caching rules |
A page is controlled by exactly one registration, so a request from /app/admin/users goes only to the admin worker. The root worker never sees it, even for subresources, because subresource requests always go to the client's controller, not to whichever scope matches the subresource URL. A page at / fetching /app/data.json is handled by the root worker, not the /app/ worker.
The hidden trap with nested scopes is the visitor who never loaded the inner section. If someone has only ever visited /, they have only the root registration. Their first navigation to /app/ is matched by the root worker, which may serve a cached marketing shell or a generic offline page instead of the app. Make the root worker's fetch handler explicitly ignore paths owned by other registrations:
const OWNED_ELSEWHERE = ["/app/"]; // scopes with their own registration
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (
url.origin === self.location.origin &&
OWNED_ELSEWHERE.some((prefix) => url.pathname.startsWith(prefix))
) {
return; // no respondWith(): the browser goes to the network as normal
}
// ... this worker's own routing ...
});
Service worker scope versus manifest scope¶
The web app manifest has its own scope member, and it is unrelated to the service worker scope. The manifest scope decides which URLs count as "inside the app" for navigation in a standalone window; the service worker scope decides which navigations the worker controls. They usually should be the same prefix, and your start_url should fall inside both, but nothing enforces that. See Members Reference and Installability Criteria.
Managing registrations¶
getRegistration(clientURL)¶
getRegistration() answers "which registration would control this URL?" It runs the same longest-prefix match that navigations use:
clientURLdefaults to"", which resolves to the document's base URL. Passlocation.hrefexplicitly if the page has a<base>element.- A URL on another origin rejects with
SecurityError; an unparseable URL rejects withTypeError. - It resolves with
undefinedif nothing matches. - It can resolve with a registration that has no active worker (only
installingorwaiting). Checkregistration.activebefore assuming anything is ready.
// Which registration will control /app/settings, and what state is it in?
const reg = await navigator.serviceWorker.getRegistration("/app/settings");
console.table({
scope: reg?.scope,
installing: reg?.installing?.state,
waiting: reg?.waiting?.state,
active: reg?.active?.state,
});
getRegistrations()¶
getRegistrations() returns every registration for the caller's storage key, as a frozen array. The storage key matters: a third-party iframe on site-a.example sees only the registrations created in that partition, not those the same origin created as a first party or under site-b.example (see storage partitioning).
ready¶
navigator.serviceWorker.ready is a promise that resolves with the registration matching this page's URL, once that registration has an active worker. Four properties of ready cause most confusion:
- It never rejects. If nothing will ever match, it stays pending forever. The spec states this explicitly.
- It is keyed to the page's URL, not to what you registered. Registering
/js/sw.jswith its default/js/scope from the page/leavesreadypending on/forever. - It resolves at
activating, notactivated. The Activate algorithm resolves pendingreadypromises right after moving the worker into the active slot and before theactivateevent runs. Code that assumes theactivatehandler's cache cleanup has finished will race it. - It does not mean the page is controlled. On a first visit without
clients.claim(),readyresolves whilenavigator.serviceWorker.controlleris stillnull.
If you depend on ready (for example to call pushManager.subscribe()), put a timeout on it so a misconfigured scope surfaces as an error instead of a silent hang:
export function readyWithTimeout(ms = 10_000) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`No active service worker for ${location.pathname} ` +
`after ${ms} ms. Check the registration scope.`,
),
),
ms,
);
});
// Clear the timer either way so a resolved `ready` leaves nothing pending.
return Promise.race([navigator.serviceWorker.ready, timeout]).finally(() =>
clearTimeout(timer),
);
}
| Question | Use | Why |
|---|---|---|
| Is this page controlled right now? | navigator.serviceWorker.controller !== null | Only controller reflects control. It is also null after a hard reload (Shift + reload). |
| Is there an active worker for this page's URL? | await navigator.serviceWorker.ready | Resolves once one exists, regardless of control. |
| What state is a specific registration in? | getRegistration(url) and its installing, waiting, active | Can observe registrations with no active worker. |
| When did control change? | controllerchange event | Fires on clients.claim() or activation of a replacement worker. |
unregister()¶
registration.unregister() resolves with true if the registration existed and was removed, false otherwise. The details matter when you use it as an escape hatch:
- Existing clients stay controlled. Unregistering removes the registration from the registration map immediately, so no new navigation will match it, but documents it already controls keep using the worker until they unload. The worker is only cleared once no client uses it and it has no pending events. Reload the affected pages if you need the change to take effect.
- Caches and IndexedDB are not touched. Cache Storage belongs to the origin, not the registration. Delete what you created explicitly.
- Push subscriptions go with it. The Push API requires that a push subscription tied to a service worker registration be deactivated when that registration is unregistered. Unregistering silently ends notifications; tell your push server.
- It can be called from the page or from inside the worker (
self.registration.unregister()), which is how a kill-switch worker removes itself. See Updating Service Workers.
/**
* Removes every registration and cache for this origin (in this partition).
* Useful for a "Reset app" button or a support page.
*/
export async function resetServiceWorkers({ reload = true } = {}) {
const regs = await navigator.serviceWorker.getRegistrations();
const results = await Promise.allSettled(regs.map((r) => r.unregister()));
if ("caches" in self) {
const keys = await caches.keys();
await Promise.allSettled(keys.map((k) => caches.delete(k)));
}
const failed = results.filter((r) => r.status === "rejected");
if (failed.length) console.warn("Some registrations failed to unregister", failed);
// Controlled documents keep their controller until they unload.
if (reload) location.reload();
}
startMessages() and the client message queue¶
Messages a service worker sends with client.postMessage() land in the page's client message queue, which starts out disabled: messages are held, not dropped, until it is enabled. The queue is enabled by whichever comes first:
- The HTML parser finishing the document: right after
DOMContentLoadedis dispatched, the HTML spec's "the end" steps enable the queue. - The first assignment to
navigator.serviceWorker.onmessage. - A call to
navigator.serviceWorker.startMessages().
addEventListener("message", …) does not enable the queue. That asymmetry exists so that code which attaches listeners late (after a lazy bundle loads, say) doesn't lose messages that arrived early, while code that wants messages before DOMContentLoaded can opt in explicitly.
Use startMessages() when you attach listeners with addEventListener() before DOMContentLoaded and want delivery to start right away, and in dedicated workers that are service worker clients (supported in Firefox and Safari), where there is no DOMContentLoaded to enable the queue for you:
// Runs from a module script in <head>, before DOMContentLoaded.
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "CACHE_UPDATED") {
showRefreshHint(event.data.url);
}
});
// Without this, messages already queued wait until DOMContentLoaded.
navigator.serviceWorker.startMessages();
The full messaging model, including MessageChannel request/response patterns and clients.matchAll(), is on Messaging & the Clients API.
Serving requirements for the script¶
The browser applies stricter rules to the worker script than to any other script, because a malicious worker persists: the spec's phrase is that it could "turn a bad day into a bad eternity". Every requirement below is checked on the first registration and on every later update fetch, so a server change months later can break updates for users who are already installed.
| Requirement | If violated | Exception | Chromium message (after the "Failed to register a ServiceWorker for scope … with script …:" prefix) |
|---|---|---|---|
Page, script and scope use http: or https: | Rejected before fetching | TypeError | "The URL protocol of the script ('…') is not supported." |
| Script is same-origin with the page | Rejected before fetching | SecurityError | "The origin of the provided scriptURL ('…') does not match the current origin ('…')." |
| Scope is same-origin with the page | Rejected before fetching | SecurityError | "The origin of the provided scope ('…') does not match the current origin ('…')." |
No %2f or %5c in either path | Rejected before fetching | TypeError | "The provided scope ('…') or scriptURL ('…') includes a disallowed escape character." |
Page CSP allows the script (worker-src) | Rejected before fetching | SecurityError | "The provided scriptURL ('…') violates the Content Security Policy." |
Response status is 2xx | Fetch fails | TypeError | "A bad HTTP response code (404) was received when fetching the script." |
| No redirect | Fetch fails | SecurityError in Chromium, TypeError per spec | "The script resource is behind a redirect, which is disallowed." |
| JavaScript MIME type | Fetch fails | SecurityError | "The script has an unsupported MIME type ('text/html')." or "The script does not have a MIME type." |
| Valid TLS certificate | Fetch fails | SecurityError in Chromium, TypeError per spec | "An SSL certificate error occurred when fetching the script." |
| Scope within max scope | Fetch fails | SecurityError | "The path of the provided scope ('…') is not under the max scope allowed ('…'). …" |
| Script evaluates without throwing | Evaluation fails | TypeError | "ServiceWorker script evaluation failed" |
| Network reachable | Fetch fails | TypeError | "An unknown error occurred when fetching the script." |
Deep dive: how Chromium turns a failed fetch into an exception
Chromium records the network error of the main script fetch and derives the rejection from it. Certificate errors, ERR_INSECURE_RESPONSE (which Chromium uses for a bad MIME type and for a scope outside the maximum scope) and ERR_UNSAFE_REDIRECT become its internal security status, which Blink surfaces as SecurityError. ERR_ABORTED becomes an abort (AbortError). Every other network error, including a non-2xx status, becomes a network status, which Blink surfaces as TypeError, as does a script that throws during evaluation. If the new worker does not start in time, the job fails with "Timed out while trying to start the Service Worker." as an AbortError. Checks done in the renderer before any fetch (URL scheme, origin, escaped slashes, CSP) reject directly with TypeError or SecurityError, as listed in the table. Firefox and Safari follow the spec more literally, so a redirect or TLS failure there is a TypeError. That is one more reason to branch on error.name only for broad categories and to log the message for diagnosis.
A "JavaScript MIME type" is any of the essences the MIME Sniffing standard lists, such as text/javascript (preferred), application/javascript or application/x-javascript. Parameters like charset=utf-8 are ignored. text/plain, application/octet-stream, application/json and text/html all fail.
What the script request looks like¶
The worker script fetch is a special request you can recognize on the server:
GET /sw.js HTTP/2
host: example.com
service-worker: script
sec-fetch-dest: serviceworker
sec-fetch-mode: same-origin
sec-fetch-site: same-origin
cache-control: max-age=0
if-none-match: "5d8c72a5edda8d6a"
Service-Worker: scriptis required by the spec on service worker script requests. It exists so administrators can log and filter them, and you can use it to refuse to serve anything other than the real worker from that URL.Sec-Fetch-Dest: serviceworkercomes from Fetch Metadata. Imported classic scripts usescript.- Conditional headers (
If-None-Match,If-Modified-Since) appear when the browser holds a cached copy, because update checks normally use the Fetch cache modeno-cache, which always revalidates with the server. The Fetch standard also addsCache-Control: max-age=0to requests in that mode. A CDN in front of your origin may answer the revalidation from its own edge cache regardless, which is why you also need a saneCache-Controlon the response (see Updating Service Workers). - The request is never intercepted by a service worker: its service-workers mode is
none. That is what makes a broken worker recoverable.
CDN and hosting pitfalls¶
The script cannot live on a CDN origin. register("https://cdn.example.net/sw.js") from https://example.com rejects with SecurityError. The spec's security section says directly that service workers "cannot be hosted on CDNs". Your options:
- Serve
/sw.jsfrom your own origin, even if the CDN fronts that origin (a CDN in front ofexample.comis fine: the origin is stillexample.com). - Keep a tiny same-origin worker and pull shared code in with
importScripts("https://cdn.example.net/lib.js"). Cross-originimportScripts()is allowed for classic workers (subject to the worker's CSP), and the imported bytes are cached with the worker at install time. - For module workers, static imports from other origins need CORS headers, since module fetches use CORS mode.
Single-page app fallbacks swallow a missing sw.js
Static hosts configured to "rewrite all unknown paths to /index.html" answer a request for a deleted or misnamed /sw.js with 200 text/html. Registration fails with the MIME error above, and worse, update checks for already-installed users fail the same way, so the old worker keeps serving the old app indefinitely. Exclude the worker path from the rewrite and let it 404 honestly. A 404 during an update check also leaves the old worker running, but at least your monitoring sees it.
Redirects are fatal, including "harmless" ones. Watch for:
httptohttpsupgrades (usually not an issue, since the page is already HTTPS);- apex to
wwwredirects when the page registered with an absolute URL on the other host (that is also a cross-origin error); - trailing-slash normalization rules that match
/sw.js; - authentication gateways (SSO, preview-deployment password walls, bot protection challenges) that redirect unauthenticated requests to a login page. Serve the worker script publicly.
CDN edge caching of sw.js. Browsers revalidate the worker on update checks, but an edge cache with a long TTL will happily answer those revalidations with the old file. Serve Cache-Control: no-cache (or max-age=0) on the worker, and purge it on deploy if your CDN caches it anyway.
Build tools that hash the worker's filename. sw.4f3a9c.js means every deploy registers a new script URL. Returning visitors' pages (served by the old worker, possibly from its cache) keep calling register() with the old URL, which is a no-op. They never learn about the new URL. Keep the worker at a stable, unhashed URL and hash everything it imports instead.
Module service workers¶
With type: "module" the worker is loaded as an ES module graph:
import { routes } from "./sw/routes.js"; // static imports: fetched and stored at install
import { openDB } from "./sw/db.js";
self.addEventListener("fetch", (event) => {
// ...
});
Module-worker rules that differ from classic workers:
- Support: Chrome and Edge 91, Safari 15, and Firefox 147 support module service workers, per MDN's compatibility data. An older browser that doesn't know the
typemember simply ignores it and parses the file as a classic script, which throws aSyntaxErroron the firstimportand rejects the registration withTypeError. importScripts()throws aTypeErrorin module workers (HTML spec).- Dynamic
import()is not allowed in service workers. The HTML spec's module-loading hook rejects any dynamic import in aServiceWorkerGlobalScopewith aTypeError. Only the static graph is fetched, and it is fetched during installation. - Top-level
awaitis forbidden. The Update algorithm checks whether the module graph is asynchronous and rejects the job withTypeErrorif it is, and deletes the registration if this was the first worker. - The
.mjsMIME trap. Plenty of servers have no mapping for.mjsand answer withapplication/octet-stream, which fails the MIME check. Map.mjstotext/javascriptor use.js. - Cross-origin imports need CORS. Static imports are fetched in CORS mode.
Because an engine without module support fails loudly instead of falling back, a registration that must also work in older browsers needs a strategy. Engine versions that predate module service workers never read the type member, and they reject the registration with a TypeError when the classic parser hits the first import. The simplest robust pattern is to try the module build and fall back to a bundled classic build on TypeError, remembering the outcome so unsupported browsers don't pay for a failed fetch on every page load:
const FALLBACK_KEY = "sw-classic-fallback";
/**
* Registers /sw.module.js as a module worker where supported, otherwise a
* bundled classic build. Both builds must implement the same behavior.
*/
export async function registerModuleOrClassic(container) {
let forceClassic = false;
try {
forceClassic = localStorage.getItem(FALLBACK_KEY) === "1";
} catch {
// Storage can be unavailable (private modes, blocked site data).
}
if (!forceClassic) {
try {
return await container.register("/sw.module.js", { scope: "/", type: "module" });
} catch (error) {
// SecurityError and friends are deployment bugs: surface them.
if (error?.name !== "TypeError" || !navigator.onLine) throw error;
try {
localStorage.setItem(FALLBACK_KEY, "1");
} catch {
/* ignore */
}
}
}
// Classic bundle: no static imports, importScripts() allowed.
return container.register("/sw.classic.js", { scope: "/" });
}
Clear the stored flag when you ship a new app version so a browser that has since gained module support gets the module build again. Switching between the two script URLs is an ordinary script-URL change for the registration: the next register() call with the other URL installs a new worker through the normal update lifecycle. If you don't need module syntax at runtime, bundling the worker into a single classic file remains the most compatible choice, and it also sidesteps any cross-engine differences in how changes to imported modules are detected (see Updating Service Workers).
Content Security Policy and Trusted Types¶
Two different policies apply, and mixing them up is a common source of confusion:
- The page's CSP decides whether the page may register the script. The governing directive is
worker-src. If it is absent, CSP Level 3 falls back tochild-src, thenscript-src, thendefault-src. MDN's compatibility data notes that Chrome 59 and later skip the deprecatedchild-srcstep, so a policy that relies onchild-srcalone to allow the worker behaves differently across engines: always stateworker-srcexplicitly. A blocked URL rejectsregister()withSecurityErrorin Chromium ("… violates the Content Security Policy.") and produces a CSP violation report. - The worker's own CSP comes from the headers on the script response. Per the spec, a
Content-Security-Policyheader onsw.jsis enforced inside the worker. It governsimportScripts()(viascript-src) and, importantly, the worker'sfetch()calls (viaconnect-src).
The second point bites teams that apply a site-wide CSP header to every response. If sw.js is served with connect-src 'self', then a pass-through fetch(event.request) for a cross-origin image, font or API call from the worker is blocked, even though the page's img-src or font-src allowed it, because inside the worker every request is a fetch(). Either serve the worker with a policy written for the worker, or make sure its connect-src covers every origin the worker proxies.
content-type: text/javascript; charset=utf-8
cache-control: no-cache
content-security-policy: default-src 'none'; script-src 'self'; connect-src 'self' https://api.example.com https://images.example-cdn.com
Trusted Types. register() accepts a TrustedScriptURL. On pages that enforce require-trusted-types-for 'script', passing a plain string is a Trusted Types violation, and since register() returns a promise, the resulting TypeError surfaces as a rejection. MDN's compatibility data lists enforcement for register() in Chrome 140 and Safari 26:
const swPolicy = window.trustedTypes?.createPolicy("sw-url", {
createScriptURL(input) {
const url = new URL(input, location.origin);
if (url.origin === location.origin && url.pathname === "/sw.js") return url.href;
throw new TypeError(`Refusing service worker URL: ${input}`);
},
});
const scriptURL = swPolicy ? swPolicy.createScriptURL("/sw.js") : "/sw.js";
await navigator.serviceWorker.register(scriptURL, { scope: "/" });
More on writing policies for PWAs is on Content Security Policy.
Subdirectories and path-based multi-app hosting¶
Apps that live under a path¶
GitHub Pages project sites (https://user.github.io/repo/), apps behind a reverse proxy at /app/, and portals that mount many apps on one origin all share one rule: register with a URL relative to where the app is deployed, and let the default scope follow the script.
// Vite exposes the configured base path; other bundlers have equivalents.
const base = import.meta.env.BASE_URL; // e.g. "/repo/"
navigator.serviceWorker.register(`${base}sw.js`, { scope: base });
Hard-coding register("/sw.js") in an app deployed at /repo/ requests https://user.github.io/sw.js, which is a 404 (or, worse, someone else's file). Passing { scope: "/" } from /repo/sw.js fails the max-scope check, and on a shared host like GitHub Pages you cannot add Service-Worker-Allowed, which is the correct outcome: you should not control other people's repositories.
Multiple apps on one origin¶
Path-scoped registrations can coexist, but a service worker's powers are origin-wide, not scope-wide. Everything below is shared across all apps on the origin:
| Shared resource | Consequence | Mitigation |
|---|---|---|
| Cache Storage names | Two apps using caches.open("static-v1") overwrite each other. | Prefix every cache name with the app ID. |
Cache cleanup in activate | The common "delete every cache not in my allowlist" code deletes the other apps' caches. | Only delete caches that carry your own prefix. |
IndexedDB, localStorage, cookies | Name collisions and shared quota. | Prefix database names; treat quota as shared. |
| Storage quota and eviction | One app's precache can push the origin toward eviction for all. | See Storage Quotas & Persistence. |
clients.matchAll({ includeUncontrolled: true }) | Returns windows of every app on the origin. | Filter by URL prefix. |
| Security boundary | None: any app can read or delete another's data. | Use separate origins (subdomains) for apps with different owners. |
Prefix-safe cleanup looks like this:
const APP = "billing"; // unique per app on this origin
const VERSION = "2026-09-25";
const CACHE_PREFIX = `${APP}:`;
const CURRENT = new Set([`${CACHE_PREFIX}static:${VERSION}`, `${CACHE_PREFIX}runtime`]);
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
// Only ever touch caches this app created.
.filter((name) => name.startsWith(CACHE_PREFIX) && !CURRENT.has(name))
.map((name) => caches.delete(name)),
);
})(),
);
});
flowchart LR
subgraph Origin["https://example.com (one origin, one storage bucket)"]
R1["Registration / (sw.js)"]
R2["Registration /billing/ (billing/sw.js)"]
R3["Registration /support/ (support/sw.js)"]
CS[("Cache Storage (shared)")]
IDB[("IndexedDB (shared)")]
end
R1 --> CS
R2 --> CS
R3 --> CS
R1 --> IDB
R2 --> IDB
R3 --> IDB For independent teams or products, separate origins (billing.example.com, support.example.com) are the clean answer: each gets its own storage, quota, permissions and install identity, and scope rules stop mattering.
Iframes, sandboxing, and storage partitioning¶
Which frames are controlled¶
Each document and each dedicated or shared worker is its own service worker client, and its controller is decided when it is created:
- A same-origin iframe is matched by its own URL like any navigation. It may be controlled by the same worker as its parent, by a different registration, or by none.
- A cross-origin iframe is never controlled by the parent's worker. Its navigation request is matched against registrations for its own origin (in the partition described below), and its subresource requests go to its own controller. The parent's worker sees neither.
about:blank,srcdocand same-originblob:documents and workers inherit the creator's active service worker, so a controlled page'ssrcdociframe is also controlled.data:URL documents and workers have opaque origins and are never controlled.- Sandboxed iframes that lack
allow-same-originorallow-scriptsare never controlled, as the spec notes. Withoutallow-same-originthe frame has an opaque origin, so it also cannot register, and in Chromium it throws on access tonavigator.serviceWorker.
Third-party iframes and storage partitioning¶
When an iframe from embed.example on news.example registers a service worker, all three engines partition the registration by the top-level site:
- Safari has partitioned third-party service workers since it first shipped them. WebKit's announcement describes a service worker registered by an
example.comiframe inside awebkit.orgpage as only able to communicate with workers and clients in the same (webkit.org,example.com) partition. - Firefox turned on service worker partitioning in Firefox 105 (bug 1784900), as part of the dynamic state partitioning behind Total Cookie Protection. The
privacy.partition.serviceWorkerspreference that gated it stayed attruefrom then on. - Chrome partitions storage, service workers and communication APIs in third-party contexts from Chrome 115; the Privacy Sandbox storage partitioning documentation explains that service workers are included because they "can alter the timing of navigation requests", which could leak cross-site information. Chrome's partition key also includes an "ancestor bit" that is set when any document between the context and the top level is cross-site, so
embed.exampleframed inside a cross-site frame is yet another partition. Google has run a series of deprecation trials (the Privacy Sandbox page currently documentsDisableThirdPartyStoragePartitioning3) that let a top-level site temporarily opt its embeds back into unpartitioned storage and service workers. Treat that as migration time, not a permanent escape hatch.
Practical consequences for embeddable widgets:
| Scenario | Result |
|---|---|
User visits embed.example directly, then sees its iframe on news.example | Two separate registrations, two installs, two sets of caches. The first-party worker never controls the iframe. |
| Widget embedded on 50 sites | Up to 50 partitioned registrations per user, each fetching and precaching independently. Keep third-party workers tiny. |
Widget calls getRegistrations() | Returns only the registrations in the current partition. |
| Widget subscribes to push inside the iframe | The subscription belongs to the partitioned registration. Treat third-party push as unreliable. |
| Parent page's worker and widget's worker | Cannot postMessage each other directly. Use window.postMessage between the frames. |
The privacy model, including which storage is partitioned and how it interacts with third-party cookie blocking, is covered on Privacy & Storage Partitioning.
Storage that can disappear¶
A registration is website data and can be removed without your code running: by the user clearing site data, by browser storage pressure, or by a Clear-Site-Data: "storage" response. WebKit also documented a 7-day cap on script-writable storage for sites without user interaction in Safari, and lists "Service Worker registrations and cache" in that set, while noting that web applications added to the Home Screen "are not part of Safari and thus have their own counter of days of use". Always write registration code that is idempotent and runs on every page load, so a deleted registration is recreated on the next visit.
Registration errors reference¶
Every rejection is either a DOMException with a meaningful name or a plain TypeError. Branch on the name, never on the message, since messages differ per engine.
error.name | Meaning | Typical causes | Retry? |
|---|---|---|---|
SecurityError | A policy check failed. | Cross-origin script or scope, scope above max scope, bad MIME type, redirect and TLS certificate errors (Chromium), CSP worker-src, cross-origin Service-Worker-Allowed, insecure origin. | No. Fix the deployment. |
TypeError | A URL, fetch or evaluation problem. | Unsupported scheme (file:, data:, blob:), %2f or %5c in a path, 404 or 5xx, network failure or offline, TLS and redirect errors (Firefox, Safari), script threw at top level, top-level await in a module, Trusted Types violation. | Only for network failures and 5xx. |
InvalidStateError | The context is not usable. | Document detached or not fully active, for example a register() from an iframe that was removed. Chromium's message: "The document is in an invalid state." | No. |
NotSupportedError | Service workers are disabled for this site (Chromium). | User blocked cookies and site data for the site: "The user denied permission to use Service Worker." | No. Degrade gracefully. |
AbortError | The operation was aborted (Chromium). | Browser shutting down ("The Service Worker system has shutdown."), the new worker failing to start in time ("Timed out while trying to start the Service Worker."). | Yes, later. |
The classic mistakes by frequency, with the fix:
SecurityError: … unsupported MIME type ('text/html'): the worker URL is served by an SPA fallback or points at the wrong path. Fetch the URL withcurl -Iand fix the path or the rewrite rule.TypeError: … A bad HTTP response code (404): the file isn't deployed at that URL, often because of a base path (/repo/) mismatch.SecurityError: … not under the max scope allowed: the script lives below the scope you asked for. Move it, drop thescopeoption, or addService-Worker-Allowed.TypeError: … ServiceWorker script evaluation failed: a syntax error, a reference towindowordocumentin worker code, a top-level exception, or a module worker shipped to a browser that parsed it as classic. The worker's console in DevTools shows the original exception.SecurityError: … behind a redirect: an auth wall, a locale redirect, or a trailing-slash rule is catching the worker URL.
Production registration code¶
The module below pulls the pieces together: capability detection, load-deferred registration, error classification for monitoring, and hooks for the update flow described on Updating Service Workers. It is framework-agnostic and has no dependencies.
/**
* Production service worker registration.
*
* - Never throws: the app must work without a service worker.
* - Defers registration until after `load` so install-time precaching
* does not compete with the page's critical resources.
* - Classifies failures so monitoring can tell "misconfigured deploy"
* (SecurityError, 404) from "user is offline" (network TypeError).
*/
const DEFAULTS = {
url: "/sw.js",
scope: "/",
type: "classic",
updateViaCache: "imports",
onError: (info) => console.warn("[sw] registration failed", info),
onRegistered: () => {},
};
/** @returns {ServiceWorkerContainer | null} */
function containerOrNull() {
if (!window.isSecureContext) return null;
try {
return navigator.serviceWorker ?? null;
} catch {
return null; // opaque origin (sandboxed iframe) in Chromium
}
}
function afterLoad() {
if (document.readyState === "complete") return Promise.resolve();
return new Promise((resolve) =>
window.addEventListener("load", () => resolve(), { once: true }),
);
}
/** Maps a rejection to a stable category for dashboards and alerting. */
export function classifyRegistrationError(error) {
const name = error?.name ?? "Error";
const message = String(error?.message ?? error);
if (name === "SecurityError") {
// Message tests match Chromium's wording; other engines fall through
// to the generic category, which is still actionable.
if (/MIME type/i.test(message)) return "bad-mime-type";
if (/max scope/i.test(message)) return "scope-too-wide";
if (/redirect/i.test(message)) return "redirect";
if (/SSL certificate/i.test(message)) return "tls-error";
if (/Content Security Policy/i.test(message)) return "csp-blocked";
return "security";
}
if (name === "TypeError") {
if (/bad HTTP response code \((\d+)\)/i.test(message)) return "http-error";
if (/evaluation failed|SyntaxError/i.test(message)) return "script-error";
if (!navigator.onLine) return "offline";
return "network-or-url";
}
if (name === "InvalidStateError") return "invalid-document";
if (name === "NotSupportedError") return "disabled-by-user";
if (name === "AbortError") return "aborted"; // shutdown or start timeout: transient
return "unknown";
}
/**
* Registers the service worker. Resolves with the registration or null.
* @param {Partial<typeof DEFAULTS>} options
*/
export async function registerServiceWorker(options = {}) {
const cfg = { ...DEFAULTS, ...options };
const container = containerOrNull();
if (!container) return null;
await afterLoad();
try {
const registration = await container.register(cfg.url, {
scope: cfg.scope,
type: cfg.type,
updateViaCache: cfg.updateViaCache,
});
// register() resolves when installation *starts*. Surface install
// failures, which do not reject the promise.
const watch = (worker) => {
if (!worker) return;
worker.addEventListener("statechange", () => {
if (worker.state === "redundant" && !registration.active) {
cfg.onError({
category: "install-failed",
name: "InstallError",
message: "The first service worker became redundant during install.",
scriptURL: worker.scriptURL,
});
}
});
};
watch(registration.installing);
registration.addEventListener("updatefound", () => watch(registration.installing));
// Sanity check: warn if this page is outside the registered scope,
// because `ready` would then never resolve here.
if (!location.href.startsWith(registration.scope)) {
console.warn(
`[sw] ${location.pathname} is outside scope ${registration.scope}; ` +
"this page will never be controlled.",
);
}
cfg.onRegistered(registration);
return registration;
} catch (error) {
cfg.onError({
category: classifyRegistrationError(error),
name: error?.name,
message: String(error?.message ?? error),
scriptURL: new URL(cfg.url, document.baseURI).href,
});
return null;
}
}
import { registerServiceWorker } from "./register-sw.js";
// Both modules are shown in full on "Updating Service Workers".
import { initUpdateFlow } from "./sw-update.js";
import { showUpdateToast } from "./update-toast.js";
registerServiceWorker({
url: "/sw.js",
scope: "/",
onRegistered: (registration) =>
initUpdateFlow(registration, {
prompt: showUpdateToast,
onStaleTab: () =>
showUpdateToast({
accept: () => location.reload(),
dismiss: () => {},
}),
}),
onError: (info) => {
// Send to your error tracker. Keep "offline" out of alerting.
if (info.category !== "offline") {
navigator.sendBeacon?.("/rum/sw-error", JSON.stringify(info));
}
},
});
And a deployment smoke test that catches most serving mistakes before users do:
#!/usr/bin/env bash
# Usage: ./check-sw.sh https://example.com/sw.js
set -euo pipefail
url="$1"
# -I: headers only. Send the same header the browser sends.
headers=$(curl -sS -I -H 'Service-Worker: script' "$url")
status=$(printf '%s' "$headers" | awk 'NR==1 {print $2}')
ctype=$(printf '%s' "$headers" | grep -i '^content-type:' | tr -d '\r' || true)
cache=$(printf '%s' "$headers" | grep -i '^cache-control:' | tr -d '\r' || true)
location=$(printf '%s' "$headers" | grep -i '^location:' | tr -d '\r' || true)
[[ "$status" =~ ^2 ]] || { echo "FAIL: status $status ${location}"; exit 1; }
echo "$ctype" | grep -Eiq 'javascript|ecmascript' || { echo "FAIL: $ctype"; exit 1; }
echo "$cache" | grep -Eiq 'no-cache|max-age=0' || echo "WARN: ${cache:-no Cache-Control}"
echo "OK: $status, $ctype, ${cache:-no Cache-Control}"
Browser support¶
| Feature | Chrome / Edge | Firefox | Safari (macOS / iOS) |
|---|---|---|---|
register(), getRegistration(), ready, unregister() | ✅ 40 / 17 | ✅ 44 | ✅ 11.1 / 11.3 |
getRegistrations() | ✅ 45 / 17 | ✅ 44 | ✅ 11.1 / 11.3 |
startMessages() | ✅ 74 / 79 | ✅ 64 | ✅ 11.1 / 11.3 |
updateViaCache option | ✅ 68 / 18 | ✅ 57 | ✅ 11.1 / 11.3 |
type: "module" | ✅ 91 / 91 | ✅ 147 | ✅ 15 / 15 |
Service-Worker-Allowed header | ✅ 42 / 16 | ✅ 40 | ✅ 11.1 / 11.3 |
CSP worker-src | ✅ 59 / 79 | ✅ 58 | ✅ 15.5 / 15.5 |
Trusted Types enforced for register() | ✅ 140 / 140 | ❌ ⚠️ | ✅ 26 / 26 |
navigator.serviceWorker in dedicated workers | ❌ | ✅ 133 | ✅ 11.1 / 11.3 |
| Partitioned third-party registrations | ✅ 115 / 115 ⚠️ | ✅ 105 | ✅ 11.1 / 11.3 |
iOS WKWebView (in-app browsers) | n/a | n/a | ❌ ⚠️ |
Support data as of September 2026. Edge versions below 79 refer to the pre-Chromium EdgeHTML engine. ⚠️ MDN lists Trusted Types enforcement for register() as a "preview" feature in Firefox, meaning Nightly builds only. ⚠️ A Chrome deprecation trial lets top-level sites temporarily opt embedded content out of partitioning. ⚠️ MDN's data marks the whole ServiceWorkerContainer interface as unsupported in iOS WebViews, so treat in-app browsers built on WKWebView as having no service worker and make sure the site works without one. For live data see MDN's ServiceWorkerContainer compatibility table and caniuse: Service Workers.
Common pitfalls¶
- Registering a script that lives in a subdirectory without a scope plan.
/js/sw.jscontrols/js/only. Move the file or useService-Worker-Allowed. - Scopes without a trailing slash.
{ scope: "/app" }matches/appletoo, and fails the max-scope check against/app/sw.js. - Relying on
readyon pages outside the scope. It never rejects; add a timeout. - Assuming
register()resolving means the worker works. Watchstatechangeforredundant. - Hashing the worker's filename. Returning users keep registering the old URL. Keep
sw.jsstable. - Letting the SPA fallback serve
sw.js. Exclude the worker path from rewrites. - Serving the worker behind redirects or auth. The worker must be publicly fetchable at its exact URL.
- A site-wide CSP header on
sw.jswith a narrowconnect-src. It blocks the worker's ownfetch()calls to other origins. activatecleanup that deletes every cache it doesn't recognize on an origin hosting several apps.- Calling
unregister()and expecting immediate effect. Controlled pages keep their worker until they unload. - Unregistering and re-registering on every load as a "fix" for update problems. It resets push subscriptions and throws away the benefits of the lifecycle. Fix the update flow instead: Updating Service Workers and Pitfalls & Anti-Patterns.
Debugging registration problems¶
- Chrome and Edge DevTools: Application → Service workers shows every registration for the current origin, its scope, the script URL, the status of installing, waiting and active workers, and links to start, stop, update and unregister them. "See all registrations" opens
chrome://serviceworker-internals, which lists every registration in the profile, including partitioned ones, with their storage keys. The Console shows the full rejection messages quoted in this page. - Firefox:
about:debugging#/runtime/this-firefoxlists registered workers with their scopes and lets you unregister or start them; DevTools also has an Application → Service Workers panel. - Safari: enable the Develop menu, then use Develop → Service Workers to open an inspector for a specific worker.
- From the console on any page:
// Everything registered for this origin (in this partition), and who controls this page.
(await navigator.serviceWorker.getRegistrations()).map((r) => ({
scope: r.scope,
script: (r.active ?? r.waiting ?? r.installing)?.scriptURL,
states: [r.installing?.state, r.waiting?.state, r.active?.state],
updateViaCache: r.updateViaCache,
}));
navigator.serviceWorker.controller?.scriptURL ?? "not controlled";
A step-by-step DevTools walkthrough is on Browser DevTools, and automated checks are on Automated Testing.
Further reading¶
On this site
- Service Worker Lifecycle: install, waiting, activate and how control changes hands.
- Updating Service Workers: the update algorithm,
updateViaCache, and update UX patterns. - Messaging & the Clients API:
postMessage,clients.matchAll()and client control. - Handling Fetch Events: what the worker does once it controls a page.
- Service Worker Security: why the serving rules exist and how workers get abused.
- Content Security Policy:
worker-src,connect-srcand Trusted Types for PWAs. - Privacy & Storage Partitioning: partitioned workers and storage in third-party contexts.
- Pitfalls & Anti-Patterns: the mistakes that cause the most production incidents.
External references
- Service Workers specification: Register, Update, Match Service Worker Registration and the extended HTTP headers.
- MDN: ServiceWorkerContainer.register()
- MDN: Service-Worker-Allowed
- MDN: CSP worker-src
- web.dev: Service worker registration
- Privacy Sandbox: Storage Partitioning
- WebKit: Workers at Your Service
- WebKit: Full Third-Party Cookie Blocking and More (the 7-day cap on script-writable storage)
- HTML Standard: the end (enabling the client message queue)