Service Worker Scope Errors Breaking Push Subscribe
Scope is the quietest way to break web push: the worker registers, the console stays clean, and pushManager.subscribe() either throws a SecurityError at registration time or never runs at all because the promise it depends on never settles.
Quick answer
A service worker can only control URLs at or below the directory its script is served from. A script at /js/sw.js gets a default scope of /js/, so it cannot control /, /pricing or /app/dashboard. Asking for a wider scope throws SecurityError unless the script’s HTTP response carries a Service-Worker-Allowed header naming the wider path. Every registration owns a separate pushManager and therefore a separate subscription endpoint, so subscribing on the wrong registration produces a subscription your users’ pages never talk to.
The three fixes, in order of preference: move the script to the origin root and serve it from /sw.js; or keep the file where your bundler puts it and add Service-Worker-Allowed: / to its response; or set up a rewrite so /sw.js serves the bundled file’s bytes at the root path.
What scope actually restricts
Scope is a URL prefix. The registration’s scope is compared against the full serialised URL of every client on the origin, and a client is controlled by the registration whose scope is the longest string prefix of that client’s URL. The restriction on how wide a scope you may request exists to stop a script uploaded to a user-content directory from taking over the whole origin. The browser therefore computes a maximum allowed scope from the script’s own path — everything at or below the directory containing the script — and rejects any requested scope outside it.
The error is explicit about all of this:
Uncaught (in promise) DOMException: 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.
Service-Worker-Allowed is a response header on the script request, not on the page. It raises the maximum allowed scope to the path it names. The browser reads it during the fetch of the worker script, before evaluating the script, so it must come from the server that serves the file — you cannot set it from inside the worker, from a <meta> tag, or from the page.
// Registering for the whole origin from a bundled script path.
async function registerForPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('push not supported in this browser');
}
let registration;
try {
registration = await navigator.serviceWorker.register('/js/sw.js', { scope: '/' });
} catch (err) {
if (err.name === 'SecurityError') {
// The server is not sending Service-Worker-Allowed: /
console.error('scope rejected — fix the header or move the script', err.message);
}
throw err;
}
// Wait for THIS registration to activate, not for navigator.serviceWorker.ready,
// which only settles once a registration controls the current page.
const worker = registration.installing || registration.waiting || registration.active;
if (worker && worker.state !== 'activated') {
await new Promise((resolve) => {
worker.addEventListener('statechange', () => {
if (worker.state === 'activated') resolve();
});
});
}
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: window.VAPID_PUBLIC_KEY_BYTES,
});
}
Serving the header depends on your stack, but the check is the same everywhere:
curl -sI https://example.com/js/sw.js | grep -i -E 'service-worker-allowed|content-type|cache-control'
# expect:
# service-worker-allowed: /
# content-type: application/javascript
# cache-control: max-age=0
If the header is absent, the browser has already computed /js/ as the maximum before your JavaScript ran, and no amount of client-side code will change it.
Prefer the root path over the header
The header works, but it is a runtime dependency on server configuration that no test in your JavaScript suite covers. It is lost when someone moves the site behind a new CDN, when a static host stops honouring custom headers for hashed asset paths, or when a bundler starts emitting the worker under a content-hashed filename. The failure then appears in production as a SecurityError that no code change caused.
Serving the worker from the origin root removes the dependency entirely. Two arrangements do this without fighting your bundler: emit the worker to dist/sw.js as a separate, unhashed entry point so it lands at /sw.js, or keep the bundled output where it is and add a server rewrite that serves those bytes at /sw.js. A rewrite is preferable to a redirect — a 301 or 302 on a worker script is treated as a registration failure, so the rewrite must be internal and return 200.
Whichever you choose, the worker script’s own response also needs a short Cache-Control lifetime. Browsers cap the freshness they will accept for a worker script at 24 hours, but a long max-age still delays the update check inside that window, which turns a scope change into a change that appears to take days to roll out.
Scope decides which pushManager you get
This is the part that turns a configuration mistake into a delivery bug. pushManager is a property of a ServiceWorkerRegistration, not of the origin. Two registrations on one origin means two independent pushManager objects, two subscribe() calls, two endpoints and two sets of keys — even though the user granted notification permission only once, because permission is origin-wide and subscriptions are not.
A common way to end up here: an old worker registered at / from a previous release, plus a new one at /app/ from a rewritten front end. A page at /app/dashboard is controlled by the /app/ registration because it is the longer matching prefix, so navigator.serviceWorker.ready resolves with that registration and your subscribe call creates an endpoint on it. Meanwhile the marketing pages at / are still controlled by the old worker, whose subscription is the one your database has been sending to for a year.
The failure has no error. Your send succeeds with 201 Created, the push service delivers to the endpoint you stored, and the worker that receives the push event is the stale one that no longer matches any open page — so notificationclick opens the wrong shell or does nothing. Deleting the old registration in a deploy script fixes the duplication but destroys the subscription with it, which is why the server needs to hear about the change through the pushsubscriptionchange handling path.
The other trap is navigator.serviceWorker.ready. That promise resolves with the registration that controls the current page, and it never rejects. If the only registration on the origin has scope /js/ and the user is on /, ready pends forever. Code shaped as await navigator.serviceWorker.ready; await reg.pushManager.subscribe(...) therefore hangs with no error, no timeout and nothing in the console — and to a developer it looks identical to the permission problems covered in pushManager.subscribe() failing with NotAllowedError. Prefer the registration object returned by register().
Diagnostic procedure
- Open DevTools → Application → Service Workers on a page where subscription fails, and tick Show all registrations from this origin. Anything other than exactly one row is a finding in itself.
- Compare the
Scopefield with the page URL. If the page URL does not begin with the scope string, this worker cannot control it — that is your bug, and no client-side change will fix it. - Read the
Clientscount. Zero clients on the registration you expect to be in charge meansnavigator.serviceWorker.readywill never settle on this page. - Check the
Status.redundantmarks a registration that has been replaced or unregistered; its push subscription is already gone even though your database still holds the endpoint. - Verify the header on the script response with the
curlcommand above. MissingService-Worker-Allowedexplains aSecurityErrorimmediately. - Enumerate registrations from the console with
navigator.serviceWorker.getRegistrations()and log eachscopealongsideregistration.pushManager.getSubscription(). Two non-null subscriptions on one origin is the duplicate-endpoint case. - Unregister every stale registration, then re-subscribe once, and post the new endpoint to your server before you delete the old row — never the other way round, or you will lose the user entirely.
| Symptom | Cause | Fix |
|---|---|---|
SecurityError on register() |
requested scope above the script’s directory | move the script to / or send Service-Worker-Allowed |
subscribe() never resolves |
awaiting navigator.serviceWorker.ready on an uncontrolled page |
use the registration returned by register() |
| Two endpoints for one user | two registrations with different scopes | unregister the stale one, re-subscribe, sync the server |
| Push arrives, click opens nothing | the receiving worker’s scope no longer matches any open page | align scope to / and consolidate the workers |
| Works after a hard reload only | the page was loaded before the worker activated | call clients.claim() in the worker’s activate handler |
Gotchas and edge cases
- Scope matching is string prefix, not path segment. A scope of
/appmatches/application/settingsas well as/app/settings. Always end a scope with a slash. - The
scopeoption resolves against the registering document’s URL, not against the script URL. Registering with{ scope: 'sw/' }from/account/profileasks for/account/sw/, which is rarely what was meant. Use absolute paths. - The header value is a path, and a wildcard is not allowed.
Service-Worker-Allowed: /is the widest legal value;*is rejected. - Changing scope creates a new registration. Re-registering the same script with a different scope does not migrate anything — the old registration, and its subscription, keep existing until you unregister them, which is the same hazard described in handling service worker updates without breaking push.
- A worker on a subdomain is a different origin.
https://cdn.example.com/sw.jscannot be registered byhttps://example.com, no header will permit it, and its subscription would belong to a different origin’s permission grant regardless.
Related
- Service Worker Registration Patterns for Web Push — the lifecycle and registration guide this failure sits inside.
- Fix pushManager.subscribe() NotAllowedError — the permission-side failures that look similar from the console.
- Handle Service Worker Updates Without Breaking Push — keeping the endpoint alive across worker replacements.
- Core Protocols & Browser Implementation — how the registration fits into the wider protocol stack.
Back to Service Worker Registration Patterns
FAQ
Why does registering a service worker from a subdirectory throw SecurityError?
Because the browser derives a maximum allowed scope from the script’s own path. A script served from the js directory can only control URLs under that directory, so requesting the origin root is refused. Either move the script so it is served from the root path, or send the Service-Worker-Allowed response header on the script request naming the wider path you need.
Does each service worker registration have its own push subscription?
Yes. The pushManager belongs to a registration, not to the origin, so two registrations mean two independent subscriptions with two endpoints and two key pairs. Notification permission is granted once per origin and is shared, which is why a second subscription can be created without any prompt and without any visible sign that the first one still exists.
Why does my subscribe call hang with no error at all?
Almost always because the code awaits navigator.serviceWorker.ready on a page that no registration controls. That promise resolves only when a registration whose scope matches the current page becomes active, and it never rejects or times out. Use the registration object returned by register, wait for its worker to reach the activated state, and call subscribe on that object instead.