Skip to content

Service worker registration and scope

In one line: navigator.serviceWorker.register(scriptURL, { scope }) installs a service worker that intercepts fetch events for every URL whose path starts with the scope. The scope defaults to the directory containing the worker script and can only be narrowed, not widened beyond that, unless the server sends a Service-Worker-Allowed header.

Calling register() is idempotent — the browser ignores the call if the identical script is already registered for that scope. The returned promise resolves to a ServiceWorkerRegistration object regardless of whether a new install was triggered.

if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js', { scope: '/' })
.then((registration) => {
console.log('Registered. Scope:', registration.scope);
})
.catch((err) => {
console.error('Registration failed:', err);
});
}

Registration should happen after the page’s main content loads, not at the top of <head>, to avoid competing with critical resources.

The scope is a URL path prefix. A service worker registered with scope /app/ intercepts:

  • /app/
  • /app/dashboard
  • /app/settings/profile

But not:

  • / (root)
  • /blog/
  • /app-launcher/ (different prefix)

The scope does not restrict what URLs the worker can request via fetch() — it only limits which page navigations and subresource requests the worker is offered to handle.

If no scope option is supplied, the scope defaults to the directory containing the worker script:

  • Worker at /sw.js → default scope /
  • Worker at /app/sw.js → default scope /app/

Placing the worker at the root (/sw.js) gives the widest default scope.

Widening scope with Service-Worker-Allowed

Section titled “Widening scope with Service-Worker-Allowed”

A worker at /app/sw.js cannot normally claim / — its scope is bounded by its own path. To grant a wider scope, the server must include the Service-Worker-Allowed response header when the browser fetches the worker script:

Service-Worker-Allowed: /

Without this header, registering /app/sw.js with { scope: '/' } throws a DOMException.

A single origin can have more than one registration. This allows different workers for different sub-apps:

// Worker A covers the marketing site
navigator.serviceWorker.register('/sw-marketing.js', { scope: '/marketing/' });
// Worker B covers the app shell
navigator.serviceWorker.register('/sw-app.js', { scope: '/app/' });

Scopes may overlap — the browser matches the most specific (longest) scope when determining which worker handles a request. Keeping scopes non-overlapping is an operational best practice, not a registration constraint.

By default the browser applies its own HTTP cache when fetching the worker script, which can delay updates if the script is aggressively cached. Set updateViaCache: 'none' to always bypass HTTP cache for the worker script itself:

navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});

The W3C Service Workers specification defines updateViaCache as controlling whether the HTTP cache is consulted for the worker script ('all'), its imported scripts ('imports', the default), or neither ('none').

  • Register after DOMContentLoaded or in a load event to avoid resource contention.
  • Place the worker script as high in the path hierarchy as the scope you need (/sw.js for /, /app/sw.js for /app/).
  • Use updateViaCache: 'none' on the registration so the browser always checks for a new worker version.
  • Log registration.scope in development to confirm the worker covers the expected paths.
  • For multi-app sites, define non-overlapping scopes and deploy separate workers per scope.