Service Workers¶
A service worker is an event-driven JavaScript worker that the browser runs separately from your pages, on behalf of an origin and a URL scope, to act as a programmable network proxy: every navigation and subresource request from the pages it controls can be intercepted, answered from a cache, synthesized, or forwarded to the network. Because it is started on demand for events (fetch, push, notification clicks, background sync) and terminated when idle, it is the piece that makes offline support, instant repeat loads, push notifications and background work possible for a Progressive Web App. Everything else in this section builds on the mechanics described here: the global scope, what you can and cannot call inside it, how long it lives, how events extend its lifetime, and which events exist in which browsers.
Key takeaways
- A service worker is registered for a scope (a URL prefix on one origin) and controls the documents and workers whose URLs fall inside that scope; it has no DOM and never shares a JavaScript heap with your pages.
- One running instance per version serves all controlled tabs. Up to three versions can exist per registration at once: installing, waiting and active.
- The browser starts the worker for an event and may terminate it whenever no event is pending: Chromium after 30 seconds idle (5 minutes maximum per event in general, 3 minutes for
sync, about 90 seconds forpush), Firefox after 30 seconds plus a 30-second grace period. Never keep state in global variables. ExtendableEvent.waitUntil()tells the browser that work is still in progress; forinstalla rejected promise discards the new version, foractivateit delays functional events until migrations finish.- Service workers require a secure context (HTTPS, or
localhost/loopback for development) and every API inside them is asynchronous: nolocalStorage, no synchronous XHR, no DOM, noimport(). - The core lifecycle events and
fetch/messageare Baseline widely available;sync,periodicsync, Background Fetch and Payment Handler events are Chromium-only.
What a service worker is¶
The Service Workers specification describes a service worker as a type of web worker that provides "generic, event-driven, time-limited script execution contexts that run at an origin." Four properties set it apart from every other script you write:
- It is persistent, but its execution is not. The registration (script URL, scope, the installed script bytes, navigation preload settings) is stored by the browser across restarts until you unregister it or the user clears site data. The running worker, by contrast, is started only to handle an event and is killed when it goes idle. The specification states it directly: "The lifetime of a service worker is tied to the execution lifetime of events and not references held by service worker clients to the
ServiceWorkerobject." - It sits in the request path. When a document is controlled by a service worker, the browser's fetch algorithm routes that document's requests (and the navigation that created it) to the worker as
fetchevents before they reach the HTTP cache or the network. The worker decides what the response is. This is why it is described as a programmable network proxy, and why a buggy one can break a site completely. - It is shared. A registration is keyed by origin plus scope URL. All tabs, iframes and workers of that origin whose URL matches the scope are served by the same active worker instance: one event loop, one thread, one set of globals.
- It can run with no page open. Push messages, notification clicks, background sync and background fetch events start the worker even when none of your pages are loaded. This is what makes re-engagement features possible and is also why the browser watches service workers so carefully for abuse.
The terms used throughout this site follow the specification:
- Service worker registration
- The persistent record tying a scope URL and a storage key (origin, partitioned by top-level site where storage partitioning applies) to up to three service workers: the installing worker, the waiting worker and the active worker. See Registration & Scope.
- Service worker client
- A document (window client), dedicated worker or shared worker. A client whose active service worker is non-null is controlled by that worker. The page-side view of this is
navigator.serviceWorker.controller. - Scope
- A URL prefix. Matching is a string prefix match (
/appalso matches/application.html), and when several registrations match, the longest scope wins. - Functional events
- Events other than the lifecycle events (
install,activate) and messaging:fetch,push,notificationclick,syncand so on. They are only dispatched to the active worker.
How a service worker fits between pages and the network¶
The mental model worth internalizing is a single background script that sits between every controlled client and everything outside the browser tab: the network, the push service and the operating system's notification center.
flowchart LR
subgraph Clients["Controlled clients (same origin, in scope)"]
T1["Tab: /inbox"]
T2["Tab: /settings"]
W1["Dedicated worker"]
end
subgraph SW["Service worker (one active instance)"]
H["Event handlers: fetch, message, push, notificationclick, sync"]
end
subgraph Storage["Origin storage"]
C[("Cache Storage")]
I[("IndexedDB")]
end
N[("Network and HTTP cache")]
P["Push service"]
OS["OS notification center"]
T1 -- "fetch events" --> H
T2 -- "fetch events" --> H
W1 -- "fetch events" --> H
T1 -.->|"postMessage"| H
H <--> C
H <--> I
H -- "fetch()" --> N
P -- "push event" --> H
H -- "showNotification()" --> OS
OS -- "notificationclick" --> H A few consequences of this picture drive most design decisions:
- Requests made by the service worker itself are never intercepted. A
fetch()from inside the worker has no controlling service worker, so it goes to the HTTP cache and network. There is no recursion and no way to "chain" service workers. - The worker is a single point of failure for the scope. If it throws inside
respondWith()or returns a broken response, every navigation in scope fails. Firefox ships a mitigation for this: itsdom.serviceWorkers.mitigations.bypass_on_faultpreference (enabled by default) bypasses the service worker after a navigation fault, and after three faults (navigation_fault_threshold) it unregisters the worker entirely, according to Firefox'sStaticPrefList.yaml. Do not count on the browser to rescue a broken release; plan your own kill switch. - Some requests never reach it. Per the spec's Handle Fetch algorithm, requests whose destination is
embedorobjectalways go to the network, a navigation initiated with a forced reload (Shift + reload) bypasses the worker and leaves the page uncontrolled, and requests from documents outside the scope or from other origins are never seen. Cross-origin subresource requests from a controlled page are intercepted (you receive them asfetchevents withmode: "cors"or"no-cors"). - Static routes can skip it. With the Static Routing API (
InstallEvent.addRoutes(), Chromium 123+ and Safari 27+), the browser evaluates declarative rules before starting the worker and can send matching requests straight to the network or cache.
Threading and execution model¶
A service worker runs on its own thread with its own event loop, separate from any page's main thread. It shares nothing with your pages except origin storage (Cache Storage, IndexedDB, cookies, OPFS) and message channels.
| Aspect | Behavior | Why it matters |
|---|---|---|
| Thread | Dedicated worker thread with its own event loop and JavaScript realm | CPU-heavy work in the worker does not jank the page, but it does delay every fetch event for every controlled tab |
| Instances | One running instance per service worker version; all controlled clients share it | Global variables are shared across tabs while the worker happens to be alive, and lost when it stops |
| Versions | Up to three per registration: installing, waiting, active | Two versions can run concurrently and touch the same caches and databases, so version your cache names and database schemas |
| Start-up | Started on demand for an event; the script is re-evaluated from the stored bytes on every start | Top-level code runs many times over the lifetime of a version; keep it cheap and side-effect free |
| Termination | Stopped when idle or when an event exceeds a browser limit | In-memory state, timers and open sockets disappear without warning |
| Communication | postMessage(), MessageChannel, BroadcastChannel, the Clients API | Messages are structured-cloned; there is no shared memory (SharedArrayBuffer cannot be shared with a service worker) |
Because the worker is re-evaluated each time it starts, the specification also captures which events you listen for only on the very first evaluation of a script version ("set of event types to handle"). Listeners added later, inside a promise callback or a setTimeout, may never be called: the Should Skip Event algorithm allows the browser to skip dispatch for any event type that was not registered during that first synchronous run. Chromium goes further with fetch: it recognizes a listener whose body is empty (self.addEventListener("fetch", () => {})) as a no-op. Since Chrome 112 it logs the console warning "Fetch event handler is recognized as no-op", and since Chrome 115 it also takes worker start-up and event dispatch off the navigation's critical path for such workers (ChromeStatus). Always register every listener synchronously at the top level of the script.
The ServiceWorkerGlobalScope¶
Inside the worker, self (and globalThis) is a ServiceWorkerGlobalScope, which inherits from WorkerGlobalScope and therefore from EventTarget. The current Web IDL is:
[Global=(Worker,ServiceWorker), Exposed=ServiceWorker, SecureContext]
interface ServiceWorkerGlobalScope : WorkerGlobalScope {
[SameObject] readonly attribute Clients clients;
[SameObject] readonly attribute ServiceWorkerRegistration registration;
[SameObject] readonly attribute ServiceWorker serviceWorker;
[NewObject] Promise<undefined> skipWaiting();
attribute EventHandler oninstall;
attribute EventHandler onactivate;
attribute EventHandler onfetch;
attribute EventHandler onmessage;
attribute EventHandler onmessageerror;
};
| Member | Type | What it gives you |
|---|---|---|
self.clients | Clients | get(id), matchAll({ type, includeUncontrolled }), openWindow(url), claim(). See Messaging & the Clients API. |
self.registration | ServiceWorkerRegistration | The registration this worker belongs to: scope, installing/waiting/active, update(), unregister(), navigationPreload, and the extension points other specs add (pushManager, showNotification(), sync, periodicSync, backgroundFetch, cookies, paymentManager, index). |
self.serviceWorker | ServiceWorker | This worker's own ServiceWorker object, so the script can read its own state and scriptURL. Chrome/Edge 79+ and Safari 15.4+; not implemented in Firefox. |
self.skipWaiting() | Promise<undefined> | Sets the skip waiting flag so this version activates as soon as it is installed, even while clients use the old one. The promise resolves almost immediately; it does not wait for activation. See Lifecycle. |
self.cookieStore | CookieStore | Asynchronous cookie access (Chrome/Edge 87+, Firefox 140+, Safari 18.4+). |
Inherited from WorkerGlobalScope | location (a WorkerLocation for the script URL), navigator (WorkerNavigator), importScripts(), fonts, timers, fetch(), caches, indexedDB, crypto, performance, reportError(), structuredClone() |
APIs available inside a service worker¶
Anything exposed to Worker in Web IDL is available in a service worker unless a specification carves it out. In practice the useful surface is:
| API | Available | Notes |
|---|---|---|
fetch(), Request, Response, Headers, AbortController | ✅ | The worker's own requests are never routed through a service worker. |
Cache Storage (caches, Cache) | ✅ | Request/response store designed for service workers. See Cache Storage API. |
IndexedDB (indexedDB) | ✅ | The only structured, transactional database available. See IndexedDB. |
Clients API (self.clients) | ✅ | Service-worker only. |
Registration APIs (self.registration.*) | ✅ | Push, notifications, sync, periodic sync and background fetch hang off the registration. |
registration.showNotification(), getNotifications() | ✅ | The only way to show a notification from a worker; new Notification() throws a TypeError in a service worker. |
importScripts() | ⚠️ | Classic workers only (throws TypeError in module workers). New URLs can only be imported during the first evaluation and install; afterwards only already-cached URLs work. |
Static import statements | ✅ | Requires register(url, { type: "module" }): Chrome/Edge 91+, Safari 15+, Firefox 147+. |
BroadcastChannel, MessageChannel, MessagePort | ✅ | Ideal for broadcasting cache updates to every tab. |
WebSocket | ✅ | Exposed to all workers, but an open socket does not keep the worker alive; the connection dies when the worker is terminated. |
EventSource | ⚠️ | The HTML standard now exposes it to every worker type; Firefox added service worker support in version 133. The same lifetime caveat as WebSocket applies. |
setTimeout(), setInterval() | ⚠️ | Work, but the spec clears the map of active timers when the worker stops; never schedule anything important with them. |
Streams (ReadableStream, TransformStream), TextEncoder/TextDecoder, CompressionStream | ✅ | The foundation of streaming responses. |
crypto.getRandomValues(), crypto.randomUUID(), crypto.subtle | ✅ | Secure contexts only, which a service worker always is. |
navigator.onLine, navigator.storage, navigator.locks, navigator.userAgent | ✅ | WorkerNavigator subset. navigator.storage.estimate() and persisted() work; persist() is exposed to windows only. |
OffscreenCanvas, createImageBitmap(), ImageData | ✅ | Exposed to workers by the HTML standard; useful for generating images or thumbnails in responses. |
performance.now(), performance.mark(), PerformanceObserver | ✅ | Timing is relative to the worker's own time origin. |
self.cookieStore and cookiechange | ⚠️ | See the support table below. |
| WebAssembly | ✅ | Subject to the worker's own Content Security Policy (the CSP header on the worker script applies). |
APIs that are not available¶
| Not available | Why | Use instead |
|---|---|---|
window, document, DOMParser, any DOM node | Workers have no document | Send data to a page with postMessage(); parse HTML with string or stream processing |
localStorage, sessionStorage | The Storage interface is [Exposed=Window] and synchronous | IndexedDB, Cache Storage, or cookieStore |
XMLHttpRequest (sync or async) | Exposed only to Window, DedicatedWorker and SharedWorker | fetch() |
FileReaderSync and any synchronous I/O | Exposed only to dedicated and shared workers; the spec forbids synchronous requests in service workers | Blob.text(), Blob.arrayBuffer(), streams |
Dynamic import() | The HTML standard's HostLoadImportedModule throws a TypeError for dynamic imports in a ServiceWorkerGlobalScope | Static import in a module worker, or importScripts() in a classic worker |
Top-level await in a module worker | The Update algorithm rejects registration if the module graph is async (Is Async Module) | Do async work inside event handlers |
new Worker(), new SharedWorker() | Worker is exposed to Window, DedicatedWorker and SharedWorker only; SharedWorker is [Exposed=Window] | Offload heavy work to a dedicated worker started by a page |
new Notification() | The Notifications API throws a TypeError when constructed in a service worker | self.registration.showNotification() |
alert(), confirm(), requestAnimationFrame(), Geolocation, Web Audio, RTCPeerConnection, MediaDevices | Not exposed to service workers (window-only, or window and dedicated workers only) | Ask a client window to do it via postMessage() |
Why every API in a service worker is asynchronous¶
A single service worker instance handles fetch events for every controlled tab. The specification's introduction notes that the design has to "keep service workers responsive in the face of a single-threaded execution model." Any synchronous call, whether a blocking XHR, a synchronous storage read or a long computation, blocks the one event loop that all pending requests are queued on. That is the reason synchronous APIs are not exposed and why expensive work (image processing, large JSON parsing) belongs in a dedicated worker started by a page rather than in the service worker.
importScripts() versus ES modules¶
Service workers come in two flavors, selected with the type option of navigator.serviceWorker.register():
// Fetched and cached during the first evaluation. The URLs are stored
// in the worker's script resource map and compared byte-for-byte on
// every update check (Chrome 78+, Firefox 56+).
importScripts("/js/sw-routes.js", "/js/sw-analytics.js");
self.addEventListener("fetch", (event) => {
// Importing a URL that was not imported during installation throws
// a NetworkError once the worker has left the "installing" state.
});
// Registered with: navigator.serviceWorker.register("/sw.js", { type: "module" })
// Static imports are fetched as part of the module graph during the update check.
import { routeRequest } from "./sw-routes.js";
// importScripts() throws a TypeError in a module worker.
// import("./lazy.js") rejects with a TypeError in any service worker.
// Top-level await makes the whole registration fail.
self.addEventListener("fetch", (event) => {
const handled = routeRequest(event);
if (handled) event.respondWith(handled);
});
In a classic worker, the specification's importScripts() hook only allows new network fetches while the worker's state is parsed or installing; after that, it returns the stored copy if the URL was imported before and a network error otherwise. Put every importScripts() call at the top level. Module workers are Baseline since Firefox 147 shipped them on 13 January 2026; if you support older Firefox versions, register a classic bundle instead, or feature-detect by trying a module registration and falling back.
Service worker lifetime: start-up, idle termination and timeouts¶
The specification deliberately leaves termination to the browser: "A user agent may terminate service workers at any time it has no event to handle, or detects abnormal operation: such as infinite loops and tasks exceeding imposed time limits (if any) while handling the events." It adds that the browser should not terminate a worker while Service Worker Has No Pending Events is false, which is exactly what waitUntil() and respondWith() influence.
Each engine turns that into concrete policy. The values below come from the engines' source code:
| Engine | When an idle worker is stopped | Upper bound for a single event | Source |
|---|---|---|---|
| Chromium (Chrome, Edge, Opera, Samsung Internet) | 30 seconds after the last event settles (kServiceWorkerDefaultIdleDelayInSeconds = 30) | 5 minutes per event or request (kRequestTimeout), 3 minutes for sync, a shorter custom timeout for push (about 90 seconds; see Advanced techniques); a new worker has 5 minutes to start, an installed one 60 seconds | service_worker.mojom, service_worker_version.h |
| Firefox (Gecko) | 30 seconds after each event (dom.serviceWorkers.idle_timeout = 30000) | A further 30 seconds during which pending waitUntil() promises or long-running script may keep it alive (dom.serviceWorkers.idle_extended_timeout = 30000), then it is terminated | all.js |
| WebKit (Safari) | Kept running while any client of the origin is open or a functional event is in flight; termination is scheduled about 10 seconds after neither is true (defaultTerminationDelay = 10_s; the 2-second defaultFunctionalEventDuration is used only when the embedder turns the termination delay off) | A worker thread that does not answer a 60-second heartbeat is treated as unresponsive: installation fails or the worker is terminated | SWServerWorker.cpp, SWServer.h |
Treat these numbers as implementation details that can change in any release. The contract you can rely on is the spec's: the worker may be stopped whenever it has nothing to do. Two practical observations follow:
- DevTools changes the behavior. Chromium does not terminate a worker while DevTools is attached to it, and WebKit keeps an inspected worker alive. Bugs caused by termination often only appear with DevTools closed. In Chrome, use the Stop link in Application → Service workers (or
chrome://serviceworker-internals) to force a stop and test cold starts. - Start-up cost is paid on the critical path. When a navigation arrives and the worker is stopped, the browser has to start a thread, evaluate your script and dispatch the
fetchevent before the request can proceed. Navigation preload and static routes exist to hide or avoid that cost.
What survives termination and what does not¶
| Survives (stored with the registration or origin) | Lost when the worker stops |
|---|---|
| The registration, its scope and the installed script bytes (including imported scripts) | Global variables, module-level let/const state, in-memory caches and Maps |
| Cache Storage, IndexedDB, OPFS, cookies | Pending setTimeout()/setInterval() timers |
| Navigation preload state and header value | Open WebSocket, EventSource and fetch() streams not tied to an extended event |
| Push subscriptions, sync and periodic sync registrations, background fetch records | Queued message events (the spec's Terminate Service Worker algorithm discards non-functional tasks) |
Static routes added with addRoutes() | Any promise chain not passed to waitUntil() or respondWith() |
Fetch and functional events that were queued when the worker stopped are not lost: the spec moves them to the registration's task queues and replays them when the worker starts again.
// WRONG: this counter resets every time the browser restarts the worker,
// which may be every 30 seconds in Chromium.
let requestCount = 0;
self.addEventListener("fetch", () => {
requestCount += 1;
});
// RIGHT: persist anything that must outlive a single event.
function openStatsDb() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("sw-stats", 1);
request.onupgradeneeded = () => request.result.createObjectStore("stats");
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function incrementVisits() {
const db = await openStatsDb();
try {
await new Promise((resolve, reject) => {
const tx = db.transaction("stats", "readwrite");
const store = tx.objectStore("stats");
const read = store.get("visits");
read.onsuccess = () => store.put((read.result ?? 0) + 1, "visits");
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close(); // do not hold connections open in a worker that may be killed
}
}
self.addEventListener("message", (event) => {
if (event.data?.type !== "RECORD_VISIT") return;
// waitUntil keeps the worker alive until the transaction commits.
event.waitUntil(incrementVisits());
});
Extendable events and waitUntil()¶
Every event dispatched to a ServiceWorkerGlobalScope is an ExtendableEvent or one of its subclasses (InstallEvent, FetchEvent, ExtendableMessageEvent, PushEvent, NotificationEvent, SyncEvent and others). The base interface is tiny:
[Exposed=ServiceWorker]
interface ExtendableEvent : Event {
constructor(DOMString type, optional ExtendableEventInit eventInitDict = {});
undefined waitUntil(Promise<any> f);
};
The semantics are what matter:
- Each extendable event has a list of extend lifetime promises and a pending promises count.
waitUntil(p)appendspand increments the count; whenpsettles, a microtask decrements it. - The event is active while its dispatch flag is set (the listeners are running) or its pending promises count is greater than zero, provided the browser has not set its timed out flag.
- Calling
waitUntil()on an event that is not active throws anInvalidStateErrorDOMException. In practice: call it synchronously inside the listener, or call it again later only while an earlierwaitUntil()promise is still pending (so-called async waitUntil, supported since Chrome 60, Firefox 53 and Safari 11.1). Calling it on an event you constructed yourself also throws, becauseisTrustedis false. - The worker is not terminated for idleness while any extended event is active, but the browser's per-event limits (for example Chromium's 5 minutes) still apply.
- What a rejected promise means depends on the event: for
install, any rejection fails the installation; foractivate, rejections are ignored but functional events wait until all promises settle; forpush, the promise should settle only aftershowNotification()has resolved, because Chromium and Safari only allow user-visible push and penalize pushes that show nothing (see Push Notifications); forsync, a rejection tells the browser to retry later.
self.addEventListener("push", (event) => {
// PushMessageData.json() throws synchronously on malformed JSON. An
// exception here would skip waitUntil() and show no notification at all.
let data = {};
try {
data = event.data?.json() ?? {};
} catch {
data = { body: event.data?.text() };
}
// Correct: waitUntil is called synchronously during dispatch.
event.waitUntil(
(async () => {
await self.registration.showNotification(data.title ?? "Update", {
body: data.body,
data: { url: data.url ?? "/" },
});
// Correct: nested waitUntil while the first promise is still pending.
event.waitUntil(logDelivery(data.id));
})()
);
});
self.addEventListener("message", (event) => {
setTimeout(() => {
// WRONG: throws InvalidStateError. The dispatch has finished and no
// lifetime promise is pending, so the event is no longer active.
event.waitUntil(Promise.resolve());
}, 0);
});
async function logDelivery(id) {
if (!id) return;
await fetch("/api/push-receipts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, receivedAt: Date.now() }),
});
}
FetchEvent adds respondWith(), which has the same "must be called synchronously during dispatch" rule and also extends the event's lifetime until the response promise settles. See Handling Fetch Events.
The secure-context requirement¶
ServiceWorkerGlobalScope, ServiceWorkerContainer, ServiceWorkerRegistration and ServiceWorker are all annotated [SecureContext], and the spec's Register algorithm rejects with a SecurityError when the script URL's origin is not potentially trustworthy. The spec's security section summarizes: service workers and their clients "need to be hosted over HTTPS. A user agent can allow localhost, 127.0.0.0/8, and ::1/128 for development purposes."
Practical implications:
navigator.serviceWorkerisundefinedonhttp://pages (other than loopback) and onfile://URLs. Always feature-detect with"serviceWorker" in navigator.- The script and scope must be same-origin with the registering page; you cannot host
sw.json a CDN (you canimportScripts()from one in a classic worker). - Sandboxed iframes without
allow-same-originhave an opaque origin and are never controlled. - Private browsing modes affect availability and persistence. Firefox only enabled service workers in Private Browsing with Firefox 140 (June 2025), storing registrations in memory and caches in encrypted storage (release notes).
- In Safari (outside Home Screen web apps), service worker registrations and caches are subject to WebKit's seven-day cap on script-writable storage for sites the user has not interacted with (WebKit blog). Plan for the registration to disappear and be recreated.
For the security model beyond HTTPS (scope restrictions, Service-Worker-Allowed, CSP for the worker script, cache poisoning) see Service Worker Security.
Every service worker event¶
The table lists every event that can be dispatched to a ServiceWorkerGlobalScope in a shipping browser, what triggers it and where it works. Only install, activate, fetch, message and messageerror are defined by the Service Workers specification itself; the rest come from specifications that extend it.
| Event | Interface | Fires when | Chromium | Firefox | Safari |
|---|---|---|---|---|---|
install | InstallEvent | A new version has been fetched and evaluated and becomes the installing worker; used to precache | ✅ | ✅1 | ✅1 |
activate | ExtendableEvent | The version becomes the active worker; used for cleanup and migrations | ✅ | ✅ | ✅ |
fetch | FetchEvent | A controlled client makes a request, or a navigation matches the scope | ✅ | ✅ | ✅ |
message | ExtendableMessageEvent | A client or another worker calls postMessage() on this worker | ✅ | ✅ | ✅ |
messageerror | ExtendableMessageEvent | A received message cannot be deserialized | ✅ | ✅ | ⚠️2 |
push | PushEvent | The push service delivers a message for the registration's subscription | ✅ | ✅ | ✅3 |
pushsubscriptionchange | PushSubscriptionChangeEvent | The subscription was refreshed, expired or revoked outside the app's control | ⚠️4 | ✅4 | ⚠️4 |
notificationclick | NotificationEvent | The user clicks a notification or one of its action buttons | ✅ | ✅ | ✅3 |
notificationclose | NotificationEvent | The user dismisses a notification | ✅ | ✅ | ⚠️3 |
sync | SyncEvent | A one-off Background Sync registration fires once connectivity allows | ✅ | ❌ | ❌ |
periodicsync | PeriodicSyncEvent | A Periodic Background Sync tag fires (installed apps, engagement-gated) | ✅ | ❌ | ❌ |
backgroundfetchsuccess | BackgroundFetchUpdateUIEvent | All requests of a Background Fetch completed | ✅ | ❌ | ❌ |
backgroundfetchfail | BackgroundFetchUpdateUIEvent | At least one background fetch request failed | ✅ | ❌ | ❌ |
backgroundfetchabort | BackgroundFetchEvent | The user or the app aborted a background fetch | ✅ | ❌ | ❌ |
backgroundfetchclick | BackgroundFetchEvent | The user clicked the browser's download progress UI | ✅ | ❌ | ❌ |
canmakepayment | CanMakePaymentEvent | A merchant's Payment Request asks whether this payment handler can pay | ✅ | ❌ | ❌ |
paymentrequest | PaymentRequestEvent | The user chose this web-based payment handler | ✅ | ❌ | ❌ |
cookiechange | ExtendableCookieChangeEvent | A cookie matching a registration.cookies.subscribe() subscription changed | ✅ | ✅ | ❌ |
contentdelete | ContentIndexEvent | The user deleted an entry registered with the Content Index API | ⚠️5 | ❌ | ❌ |
Chromium (since Chrome 70) also dispatches an abortpayment event to payment handlers when the merchant aborts an in-progress payment; no other engine implements it. None of the Chromium-only events should be required for your app to work; treat them as progressive enhancements behind feature detection ("sync" in registration, "periodicSync" in registration, "backgroundFetch" in registration).
A minimal, production-safe service worker¶
The smallest useful service worker gives navigations an offline fallback page and nothing else. It is a good first deployment because it cannot serve stale application code: every request except failed navigations goes to the network exactly as before.
// Load this from every page, for example with <script type="module" src="/register-sw.js">.
if ("serviceWorker" in navigator) {
// Registering after "load" keeps the worker's install-time downloads from
// competing with the first visit's critical requests.
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
// Bypass the HTTP cache for the worker script and its imports when
// checking for updates.
updateViaCache: "none",
});
console.info("[sw] registered for", registration.scope);
} catch (error) {
// TypeError: the script failed to download, returned an HTTP error,
// redirected, or threw during evaluation.
// SecurityError: insecure origin, wrong MIME type, or a scope outside
// the maximum allowed by the script location / Service-Worker-Allowed.
console.error("[sw] registration failed:", error);
}
});
}
const CACHE_PREFIX = "offline-fallback-";
const CACHE_NAME = `${CACHE_PREFIX}v1`;
const OFFLINE_URL = "/offline.html";
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
// cache: "reload" skips the HTTP cache so a stale fallback is never stored.
// If this rejects, installation fails and the old version stays in charge.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
})()
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Only delete caches this script owns; other code on the origin
// (a framework, Workbox) may use Cache Storage too.
const names = await caches.keys();
await Promise.all(
names
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
// Navigation preload lets the browser start the navigation request
// while the worker boots.
if (self.registration.navigationPreload) {
await self.registration.navigationPreload.enable();
}
})()
);
});
self.addEventListener("fetch", (event) => {
// Only handle page navigations; returning without calling respondWith()
// lets the browser perform its default network fetch.
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
try {
const preloaded = await event.preloadResponse;
if (preloaded) return preloaded;
return await fetch(event.request);
} catch (error) {
// fetch() rejects only on network failure (offline, DNS, CORS),
// never on HTTP 404 or 500, so this is the offline path.
const cache = await caches.open(CACHE_NAME);
const fallback = await cache.match(OFFLINE_URL);
return fallback ?? Response.error();
}
})()
);
});
Things to notice:
- The
installhandler useswaitUntil()so that a failed download fails the installation. A version that cannot serve its fallback never becomes active. - The
activatehandler only removes caches with its own prefix and enables navigation preload, which requires an active worker, soactivateis the correct place. - The
fetchhandler returns early for non-navigation requests. Not callingrespondWith()is the cheapest way to let the browser handle a request normally. - There is no
skipWaiting()orclients.claim(). A new version waits until all tabs are closed, which is the safest default. The trade-offs are explained in Lifecycle and Updating Service Workers.
From here, the caching strategies and precaching pages show how to grow this into a full offline experience, and Workbox packages the same patterns as a library.
Browser support¶
Support data as of September 2026. Check MDN's Service Worker API compatibility tables and caniuse for live data.
| Feature | Chrome / Edge | Firefox | Safari (macOS) | Safari (iOS / iPadOS) |
|---|---|---|---|---|
Service workers (core: register, lifecycle, fetch, Cache Storage) | ✅ 45 / 17 | ✅ 44 | ✅ 11.1 | ✅ 11.3 |
Async waitUntil() (while other promises are pending) | ✅ 60 / 17 | ✅ 53 | ✅ 11.1 | ✅ 11.3 |
updateViaCache registration option | ✅ 68 / 18 | ✅ 57 | ✅ 11.1 | ✅ 11.3 |
| Navigation preload | ✅ 59 / 18 | ✅ 99 | ✅ 15.4 | ✅ 15.4 |
ES module service workers (type: "module") | ✅ 91 / 91 | ✅ 147 | ✅ 15 | ✅ 15 |
self.serviceWorker | ✅ 79 / 79 | ❌ | ✅ 15.4 | ✅ 15.4 |
self.cookieStore | ✅ 87 / 87 | ✅ 140 | ✅ 18.4 | ✅ 18.4 |
cookiechange event and registration.cookies | ✅ 87 / 87 | ✅ 140 | ❌ | ❌ |
Static Routing API (InstallEvent.addRoutes()) | ✅ 123 / 123 | ❌ | ✅ 27 | ✅ 27 |
The Chrome 45 in the first row is the version the web-features project (which computes Baseline) uses for the feature as a whole, including Cache Storage in pages; MDN lists the individual interfaces (register(), install, fetch) from Chrome 40, which is why the Lifecycle support table starts at 40. The core feature set is Baseline widely available: it became interoperable across all major engines in April 2018 (Safari 11.1 and Edge 17) and widely available in October 2020. ES module service workers reached Baseline newly available on 13 January 2026 with Firefox 147. Edge version numbers below 79 refer to the original EdgeHTML engine. Firefox enabled service workers in Private Browsing windows in Firefox 140. Storage limits and eviction rules per browser are covered in Storage Quotas & Persistence and Safari specifics in iOS & iPadOS.
What service workers cannot do¶
It is as important to know the limits as the capabilities:
- They cannot keep running on their own. There is no API to keep a worker alive indefinitely. Long-running work must be expressed as events (
sync,periodicsync, background fetch) that the browser schedules, and those are Chromium-only. - They cannot see other origins' traffic. A worker only sees requests from clients it controls. A request from
https://other.exampleto your API is handled by that origin's service worker, if any. - They cannot control a page that loaded before them unless they call
clients.claim(), and they never control a force-reloaded page. See why the first page load is not controlled. - They cannot intercept their own script requests. The worker script and everything it imports are fetched with service workers bypassed (the spec sets the request's service-workers mode to
"none"), and the main script request carries aService-Worker: scriptheader you can check on the server. - They cannot guarantee storage. Caches are subject to quota and eviction unless the origin holds persistent storage, and Safari applies its own caps.
Common pitfalls¶
The mistakes that break production sites most often are covered in detail in Pitfalls & Anti-Patterns. The short list:
- Adding event listeners asynchronously, so the browser never dispatches the event.
- Keeping state in globals and losing it when the worker is stopped after 30 seconds.
- Calling
waitUntil()orrespondWith()after anawait, which throwsInvalidStateError. - Serving
sw.jswith a longCache-Control: max-ageand a versioned filename, making updates unreachable; keep the URL stable and let update checks do their job. - Calling
skipWaiting()unconditionally while old pages still expect old cached assets. - Shipping without a kill switch: a no-op replacement
sw.jsyou can deploy if a release goes wrong.
Debugging¶
Every browser exposes the registration, the worker's state and a console for the worker:
- Chrome and Edge: DevTools → Application → Service workers shows each version's state, the Update, Unregister, Push, Sync and Stop/Start controls, and the Update on reload and Bypass for network checkboxes.
chrome://serviceworker-internalslists every registration in the profile, andchrome://inspect/#service-workerslets you attach DevTools to any running worker. - Firefox:
about:debugging#/runtime/this-firefoxlists registered service workers with Start, Inspect and Unregister buttons; the Application panel in DevTools shows the registration for the current page. - Safari: Develop → Service Workers lists running workers by origin and opens a dedicated Web Inspector.
Full workflows, including testing offline behavior and simulating push, are on Browser DevTools and Automated Testing.
Explore this section¶
-
Lifecycle
Registration, install, waiting, activate and redundant;
skipWaiting(),clients.claim(),controllerchangeand the spec's job queue. -
Registration & Scope
register()options, scope matching,Service-Worker-Allowed, multiple registrations per origin and unregistering. -
Updating Service Workers
Byte-for-byte update checks,
updateViaCache,registration.update(), update prompts and safe rollouts. -
Handling Fetch Events
FetchEventin depth:respondWith(), request modes and destinations, opaque responses, redirects and range requests. -
Navigation Preload
Start the navigation request in parallel with worker boot-up and consume it with
event.preloadResponse. -
Messaging & the Clients API
postMessage(),MessageChannel,BroadcastChannel,clients.matchAll(),openWindow()andfocus(). -
Static Routing API
Declarative
addRoutes()rules that let the browser skip the worker for network-only or cache-first paths. -
Advanced Techniques
Module workers, bundling and TypeScript, event lifetime limits, Web Locks, WebAssembly, the Cookie Store API, edge-like request patterns and multiple registrations.
-
Pitfalls & Anti-Patterns
The failure modes that take sites down, how to detect them and how to recover with a kill switch.
Further reading¶
On this site
- Core Building Blocks: where the service worker fits alongside the manifest and HTTPS
- Tutorial: Your First PWA: a complete app with a service worker, step by step
- Caching Strategies: cache-first, network-first, stale-while-revalidate and when to use each
- Offline UX & Fallbacks: what to show when the network is gone
- Push Notifications: the
pushandnotificationclickevents end to end - Workbox Fundamentals: the service worker library most production PWAs use
- Service Worker Security: scope, CSP and cache-poisoning risks
- API Cheat Sheet: every service worker API on one page
External references
- Service Workers (W3C Editor's Draft) — the normative algorithms referenced on this page
- MDN: Service Worker API
- MDN: ServiceWorkerGlobalScope
- MDN: Functions and classes available to workers
- web.dev: The service worker lifecycle
- Chrome for Developers: Skip service worker no-op fetch handler (ChromeStatus)
- WebKit Features for Safari 27.0
- caniuse: Service Workers
-
Firefox dispatches
installas a plainExtendableEvent, not anInstallEvent. Safari did the same until Safari 27, which added theInstallEventinterface together with the Static Routing API, soevent.addRoutes()is available in Chromium (123+) and Safari 27+ only. Feature-detect with"addRoutes" in event. ↩↩ -
Safari exposes
onmessageerrorbut never fires the event (WebKit bug 272967). ↩ -
Safari supports push and notification events on macOS 13 Ventura and later (Safari 16+). On iOS and iPadOS they are only delivered to web apps added to the Home Screen, from 16.4. MDN's compatibility data currently marks
notificationclickandnotificationcloseas unsupported on iOS even though Home Screen web apps must handlenotificationclickto open the app; treat that data as incomplete and testnotificationcloseon devices before relying on it.pushsubscriptionchangeis a different case: it is not available on iOS and iPadOS (see the footnote on that row). See Web Push on iOS & Safari. ↩↩↩ -
Chromium (Chrome and Edge 138+) fires
pushsubscriptionchangeonly when notification permission is re-granted to an origin whose subscription was dropped when permission was revoked;oldSubscriptionandnewSubscriptionarenullin that case. Firefox has fired the event since Firefox 44 and exposesoldSubscriptionandnewSubscriptionsince Firefox 137. Safari supports it on macOS from Safari 16; it is not available on iOS and iPadOS (MDN:safari_ios: false). ↩↩↩ -
The Content Index API and its
contentdeleteevent exist only in Chrome for Android (84+) and Samsung Internet. ↩