A service worker script is served from /static/js/sw.js and the page calls navigator.serviceWorker.register('/static/js/sw.js', { scope: '/' }). Why does that registration fail, and what would make it work?
answer
- scope is a prefix on client URLs
- default scope is the script's directory
- file location proves authority over path
- a response header can widen it
- think about user-uploaded scripts
basics
~20 sA worker may only control URLs at or below the directory its script is served from, so a script at /static/js/ cannot claim the scope /. The register() promise rejects with a SecurityError unless the script's HTTP response carries the Service-Worker-Allowed header naming the wider scope.
solid answer
~50 sThe maximum scope a registration may claim defaults to the path of the script's own URL — here `/static/js/`. Asking for `/` is asking to control more of the origin than the script's location vouches for, so `register()` rejects with a `SecurityError`. There are two fixes. The usual one is to serve the worker from the root: `/sw.js` gets a default scope of `/` with no configuration. The other is to keep the script where your build puts it and have the server send `Service-Worker-Allowed: /` on the response for `sw.js`; that header raises the maximum scope, and the explicit `{ scope: '/' }` option is then accepted. The reason for the restriction is that hosting a file under a path is treated as evidence of control over that path — it stops a user-uploaded script under `/uploads/` from intercepting the whole origin.
code
javascript · 6 linesnavigator.serviceWorker
.register('/static/js/sw.js', { scope: '/' })
.then((reg) => console.log('scope:', reg.scope))
.catch((err) => console.error(err.name, err.message));
// Without `Service-Worker-Allowed: /` on the script response:
// SecurityError: The path of the provided scope is not under the max scope allowed.go deeper
Remember the simple rule: put sw.js at the site root so its scope is the whole origin, and know that a worker in a subdirectory only controls that subdirectory.
Explain the max-scope rule and the Service-Worker-Allowed response header, and articulate why the platform ties authority to where the script is served from.
Show you know scope selects controlled documents rather than interceptable URLs, that the longest matching scope wins across registrations, and that a header-stripping proxy can break registration only in production.
Decide the registration topology deliberately: one root worker versus per-area scopes, who owns the file's deploy path, and whether depending on a custom response header is acceptable in your CDN and proxy chain.
## Scope decides which pages the worker controls A registration's scope is a URL. Any client whose URL starts with that string — a plain prefix match on the serialized URL — is eligible to be controlled by that registration. `scope: '/app/'` controls `/app/`, `/app/settings`, `/app/a/b/c`, but not `/help`. Because it is a string prefix and not a path-segment match, a scope of `/app` (no trailing slash) also matches `/apple.html`, which is a small but real footgun; write scopes with a trailing slash. ## The default and the maximum are both derived from the script URL When you omit the option, the scope defaults to the directory part of the script's URL. `/sw.js` → `/`. `/static/js/sw.js` → `/static/js/`. Separately, the *maximum* allowed scope is derived the same way, and an explicit `scope` option is validated against it. So: - `register('/sw.js')` → scope `/` ✅ - `register('/sw.js', { scope: '/app/' })` → narrower than the max, ✅ - `register('/static/js/sw.js', { scope: '/' })` → wider than the max, ❌ rejects with `SecurityError` The rejection is a promise rejection, so it is easy to swallow. Always attach a `.catch()` to `register()` — a silently rejected registration looks exactly like a worker that never updates. ## Widening the maximum: Service-Worker-Allowed The server can raise the ceiling by sending a response header on the worker script itself: ``` HTTP/1.1 200 OK Content-Type: text/javascript Service-Worker-Allowed: / ``` With that header on `/static/js/sw.js`, the explicit `{ scope: '/' }` registration succeeds. Note both halves are required: the header raises the maximum, and you still pass the `scope` option, because the *default* is still the script's directory. This matters in practice when a bundler emits hashed asset paths, or when a CDN serves your JS from a subdirectory while the app lives at the origin root. ## Why the restriction exists Controlling a scope is a lot of power — the worker sees and can answer every request from every page under it. The platform uses "where the file is served from" as a cheap proof of authority over that path. Any site that lets users upload files into a directory would otherwise be handing each uploader the ability to intercept the entire origin. The `Service-Worker-Allowed` header is the deliberate opt-in: only something that can set response headers — that is, the site operator, not an uploader — can widen the scope. ## Related rules worth knowing - **Scope selects clients, not URLs.** A page inside the scope is controlled, and then *all* of that page's requests go to the `fetch` handler — including requests for URLs outside the scope and even cross-origin ones. Scope is about which documents are controlled, not about which request URLs are interceptable. - **Overlapping registrations.** An origin may hold several registrations. For a given client URL, the registration with the longest matching scope wins; only one worker controls a document. - **Same origin only.** The script URL must be same-origin with the page; you cannot register a worker from a CDN on another origin. Serve it from your own origin, proxying if you must. - **Secure context.** HTTPS, or `localhost` for development. - **The scope you get back.** `registration.scope` is the absolute, resolved URL — log it when debugging, since a relative script path resolves against the *page's* URL, which is a frequent source of surprise on nested routes. ## Practical guidance Put the worker at the root when the app owns the origin: `/sw.js`, no options, no headers, nothing to explain to the next engineer. Reach for `Service-Worker-Allowed` only when your build pipeline genuinely cannot emit a root-level file, and when you do, treat it as a deployment requirement — a cache or proxy tier that strips unknown response headers will break registration in production while everything works locally.
- If a controlled page requests a cross-origin image, does the worker's fetch handler see it?Yes. Scope decides which *documents* are controlled; once a document is controlled, every request it makes is dispatched to the worker's `fetch` handler, including subresources outside the scope and cross-origin ones. The response you get back for a cross-origin request may be opaque, but the event still fires and you can choose to pass it through.
- Two registrations exist on one origin with scopes '/' and '/app/'. Which controls a page at /app/settings?The one scoped to `/app/`. When several registrations match a client URL, the longest matching scope wins, and exactly one worker controls a document. Overlapping registrations are legal but confusing in practice — teams usually keep a single root registration, or make the boundary a real product boundary.
- What breaks if a proxy tier strips the Service-Worker-Allowed header in production?Registration starts rejecting with `SecurityError` for every user, while local development still works because there the header survives. The app silently loses offline behaviour and updates. It is a good argument for serving the worker from the origin root instead, and for logging registration failures to your error tracker rather than swallowing them.
saying these in an interview costs you the question
- Thinks scope only affects which URLs get intercepted
- Believes any path can claim the origin root
- Registers a worker from a CDN on another origin
- Forgets that register() rejects, and swallows the error
- Assumes scope matching respects path segments, not prefixes