Skip to content

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 the install handler later fails.
  • The default scope is the script's directory (./ resolved against the script URL). A wider scope needs the Service-Worker-Allowed response header on the script.
  • Scope matching is a plain string prefix match on the full URL, and the longest matching scope wins. /app also matches /apple, so always end scopes with /.
  • The script must be same-origin, served over HTTPS (or localhost), with a JavaScript MIME type, a 2xx status and no redirects. SPA fallbacks that return index.html for a missing sw.js are 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: SecurityError means a policy violation, TypeError means a fetch, URL or evaluation failure, and InvalidStateError means 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:

Service Workers spec (excerpt, WebIDL)
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:

  1. Register: reject with SecurityError if the script URL's origin is not potentially trustworthy, or if the script or scope origin differs from the registering page's origin.
  2. 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.
  3. 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.
  4. Install sets the registration's installing worker, resolves the register() promise, fires updatefound, then dispatches the install event.

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-origin have an opaque origin. In Chromium, merely reading navigator.serviceWorker throws a SecurityError with 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, but register() rejects with a TypeError because only http: and https: are allowed (Chromium's message reads "The URL protocol of the current origin (…) is not supported.").
  • Firefox private windows exposed navigator.serviceWorker as undefined before 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 a NotSupportedError whose message ends in "The user denied permission to use Service Worker."
  • Embedded WebViews: MDN's compatibility data lists register() as unsupported in iOS WKWebView, which many in-app browsers use.

A robust detection helper therefore guards the property access itself:

sw-support.js
/**
 * 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-secure on 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:

main.js
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 load event fires early but the app keeps hydrating afterwards, you can defer further with requestIdleCallback(). Safari does not ship it (MDN lists it only behind a flag in Safari Technology Preview), so feature-detect it and fall back to setTimeout().
  • 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 for load by default. Its register({ 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:

  1. Both scriptURL and scope resolve 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 where register("sw.js") points.
  2. 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.
  3. Fragments are stripped from both URLs, and a path containing %2f or %5c (encoded / or \, any case) is rejected with a TypeError. 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:

max scope examples
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:

Chromium console
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:

Response for /assets/sw.js
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.js also 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: "/" } to register(), 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 SecurityError and the old worker keeps running.

Server configurations for serving a worker from a subdirectory with a root scope:

nginx.conf
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
}
.htaccess
<Files "sw.js">
    Header set Service-Worker-Allowed "/"
    Header set Cache-Control "no-cache"
    ForceType text/javascript
</Files>
server.js
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"));
});
_headers
/assets/sw.js
  Service-Worker-Allowed: /
  Cache-Control: no-cache
firebase.json
{
  "hosting": {
    "headers": [
      {
        "source": "/assets/sw.js",
        "headers": [
          { "key": "Service-Worker-Allowed", "value": "/" },
          { "key": "Cache-Control", "value": "no-cache" }
        ]
      }
    ]
  }
}
vercel.json
{
  "headers": [
    {
      "source": "/assets/sw.js",
      "headers": [
        { "key": "Service-Worker-Allowed", "value": "/" },
        { "key": "Cache-Control", "value": "no-cache" }
      ]
    }
  ]
}

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/app matches https://example.com/app/, https://example.com/app.html, https://example.com/apple-pie and https://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=en only 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, so https://example.com can never be a prefix of https://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 without clients.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:

sw.js (root scope)
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:

  • clientURL defaults to "", which resolves to the document's base URL. Pass location.href explicitly if the page has a <base> element.
  • A URL on another origin rejects with SecurityError; an unparseable URL rejects with TypeError.
  • It resolves with undefined if nothing matches.
  • It can resolve with a registration that has no active worker (only installing or waiting). Check registration.active before assuming anything is ready.
console
// 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:

  1. It never rejects. If nothing will ever match, it stays pending forever. The spec states this explicitly.
  2. It is keyed to the page's URL, not to what you registered. Registering /js/sw.js with its default /js/ scope from the page / leaves ready pending on / forever.
  3. It resolves at activating, not activated. The Activate algorithm resolves pending ready promises right after moving the worker into the active slot and before the activate event runs. Code that assumes the activate handler's cache cleanup has finished will race it.
  4. It does not mean the page is controlled. On a first visit without clients.claim(), ready resolves while navigator.serviceWorker.controller is still null.

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:

ready-with-timeout.js
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.
reset-service-workers.js
/**
 * 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:

  1. The HTML parser finishing the document: right after DOMContentLoaded is dispatched, the HTML spec's "the end" steps enable the queue.
  2. The first assignment to navigator.serviceWorker.onmessage.
  3. 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:

early-messages.js
// 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:

Update check request for a classic worker (illustrative)
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: script is 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: serviceworker comes from Fetch Metadata. Imported classic scripts use script.
  • Conditional headers (If-None-Match, If-Modified-Since) appear when the browser holds a cached copy, because update checks normally use the Fetch cache mode no-cache, which always revalidates with the server. The Fetch standard also adds Cache-Control: max-age=0 to 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 sane Cache-Control on 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.js from your own origin, even if the CDN fronts that origin (a CDN in front of example.com is fine: the origin is still example.com).
  • Keep a tiny same-origin worker and pull shared code in with importScripts("https://cdn.example.net/lib.js"). Cross-origin importScripts() 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:

  • http to https upgrades (usually not an issue, since the page is already HTTPS);
  • apex to www redirects 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:

main.js
await navigator.serviceWorker.register("/sw.js", { type: "module" });
sw.js
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 type member simply ignores it and parses the file as a classic script, which throws a SyntaxError on the first import and rejects the registration with TypeError.
  • importScripts() throws a TypeError in module workers (HTML spec).
  • Dynamic import() is not allowed in service workers. The HTML spec's module-loading hook rejects any dynamic import in a ServiceWorkerGlobalScope with a TypeError. Only the static graph is fetched, and it is fetched during installation.
  • Top-level await is forbidden. The Update algorithm checks whether the module graph is asynchronous and rejects the job with TypeError if it is, and deletes the registration if this was the first worker.
  • The .mjs MIME trap. Plenty of servers have no mapping for .mjs and answer with application/octet-stream, which fails the MIME check. Map .mjs to text/javascript or 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:

register-module-or-classic.js
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 to child-src, then script-src, then default-src. MDN's compatibility data notes that Chrome 59 and later skip the deprecated child-src step, so a policy that relies on child-src alone to allow the worker behaves differently across engines: always state worker-src explicitly. A blocked URL rejects register() with SecurityError in 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-Policy header on sw.js is enforced inside the worker. It governs importScripts() (via script-src) and, importantly, the worker's fetch() calls (via connect-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.

Headers for /sw.js with a worker-specific policy
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:

register-with-trusted-types.js
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.

main.js
// 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:

app/sw.js
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, srcdoc and same-origin blob: documents and workers inherit the creator's active service worker, so a controlled page's srcdoc iframe is also controlled.
  • data: URL documents and workers have opaque origins and are never controlled.
  • Sandboxed iframes that lack allow-same-origin or allow-scripts are never controlled, as the spec notes. Without allow-same-origin the frame has an opaque origin, so it also cannot register, and in Chromium it throws on access to navigator.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.com iframe inside a webkit.org page 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.serviceWorkers preference that gated it stayed at true from 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.example framed inside a cross-site frame is yet another partition. Google has run a series of deprecation trials (the Privacy Sandbox page currently documents DisableThirdPartyStoragePartitioning3) 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:

  1. SecurityError: … unsupported MIME type ('text/html'): the worker URL is served by an SPA fallback or points at the wrong path. Fetch the URL with curl -I and fix the path or the rewrite rule.
  2. TypeError: … A bad HTTP response code (404): the file isn't deployed at that URL, often because of a base path (/repo/) mismatch.
  3. SecurityError: … not under the max scope allowed: the script lives below the scope you asked for. Move it, drop the scope option, or add Service-Worker-Allowed.
  4. TypeError: … ServiceWorker script evaluation failed: a syntax error, a reference to window or document in 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.
  5. 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.

register-sw.js
/**
 * 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;
  }
}
main.js
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:

check-sw.sh
#!/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.js controls /js/ only. Move the file or use Service-Worker-Allowed.
  • Scopes without a trailing slash. { scope: "/app" } matches /apple too, and fails the max-scope check against /app/sw.js.
  • Relying on ready on pages outside the scope. It never rejects; add a timeout.
  • Assuming register() resolving means the worker works. Watch statechange for redundant.
  • Hashing the worker's filename. Returning users keep registering the old URL. Keep sw.js stable.
  • 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.js with a narrow connect-src. It blocks the worker's own fetch() calls to other origins.
  • activate cleanup 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-firefox lists 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:
console
// 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

External references