Skip to content

Service Worker · Concept

Service worker registration and scope

Published

A service worker registration pairs one script URL with one scope, a URL prefix on the same origin; the browser offers the worker every navigation and subresource request whose URL starts with that prefix. register() has shipped unchanged since Chrome 40, Firefox 44, Safari 11.1, and Edge 17 (BCD api.ServiceWorkerContainer.register), and the updateViaCache option since Chrome 68, Firefox 57, and Safari 11.1.

navigator.serviceWorker.register(scriptURL, { scope, type, updateViaCache }) returns a Promise<ServiceWorkerRegistration>. The call is idempotent: repeating it with the same script and scope resolves to the existing registration without starting an install, so a page may call it on every load. A different script URL for the same scope replaces the registration (the new worker goes through the normal install and waiting sequence); a different scope creates a second registration.

The scope defaults to the directory of the script: /sw.js gives /, /app/sw.js gives /app/. A registration may narrow its scope with the option (/app/sw.js with scope: '/app/admin/') but may not widen it beyond the script’s directory unless the response that delivered the script carries a Service-Worker-Allowed header naming the wider path. Without that header, register('/app/sw.js', { scope: '/' }) rejects with a SecurityError whose Chromium text is The path of the provided scope ('/') is not under the max scope allowed ('/app/'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope..

Scope matching is a plain string-prefix test on the URL, not a path-segment test: scope /app also matches /application/, so end scopes with a slash. When several registrations on one origin overlap, the browser selects the one with the longest matching scope for each request, so /app/admin/ wins over /app/ for /app/admin/users. The scope limits only which clients the worker controls; the worker’s own fetch() calls are unrestricted.

updateViaCache governs the HTTP cache during update checks. 'imports' (the default) fetches the main script bypassing the HTTP cache but lets importScripts() dependencies come from it; 'all' allows the HTTP cache for both; 'none' bypasses it for both. Independently of this setting, the browser ignores HTTP freshness older than 24 hours for the main script. The type: 'module' option (Chrome 91, Firefox 114, Safari 15) lets the script use import statements; module workers cannot call importScripts().

Registration requires a secure context: navigator.serviceWorker is undefined on plain HTTP origins other than localhost, so the feature test 'serviceWorker' in navigator is also the HTTPS test. The script must be served with a JavaScript MIME type; Chromium rejects text/html (the usual result of a 404 page or an SPA catch-all route) with Failed to register a ServiceWorker for scope ('https://example.com/') with script ('https://example.com/sw.js'): The script has an unsupported MIME type ('text/html')..

The first example registers with an explicit scope and surfaces the two rejection causes above; the second shows two workers sharing an origin and how to confirm which one controls a page.

Registering with an explicit scope and reporting failures

Section titled “Registering with an explicit scope and reporting failures”

Registering after the load event keeps the script fetch out of the critical path of the first render. The catch branch distinguishes the MIME-type failure (a deployment problem) from a scope failure (a configuration problem) by inspecting the error name and message.

if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});
console.log('scope:', registration.scope); // "https://example.com/"
} catch (err) {
if (err.name === 'SecurityError') {
console.error('scope not allowed; serve Service-Worker-Allowed or move sw.js', err.message);
} else {
console.error('registration failed', err.message); // MIME type, network, parse error
}
}
});
} else {
// Insecure origin or unsupported browser: the app stays online-only.
}

updateViaCache: 'none' costs one uncached request for the script and its imports on every navigation into scope; the gain is that a Cache-Control: max-age=31536000 header on the script can no longer delay an update for up to a day.

A marketing site and an app under one origin can run separate workers so that a cache change in one does not invalidate the other. The longest-scope rule routes each request; navigator.serviceWorker.controller.scriptURL in a page confirms the result.

await navigator.serviceWorker.register('/sw-site.js', { scope: '/' });
await navigator.serviceWorker.register('/app/sw-app.js', { scope: '/app/' });
// In a page at /app/dashboard, after a reload:
console.log(navigator.serviceWorker.controller.scriptURL); // ".../app/sw-app.js"
// Enumerate every registration on the origin
const all = await navigator.serviceWorker.getRegistrations();
console.log(all.map((r) => r.scope));

The cost of two workers is two install and update cycles to reason about and no shared Cache Storage namespace by default (both see the same caches, so cache names must not collide). A single worker at /sw.js with routing inside the fetch handler avoids that at the cost of one larger script.

Specifications

SpecificationStatus
None.