cf.completefrontend
My progress
FUNDAMENTALS · MODULE 14

Service workers

A script the browser runs beside your pages, with no DOM and no memory it can trust, that wakes up for events (fetch, message, push, sync), handles them, and is shut down when idle: a programmable network proxy with sharp limits.

Why a worker that outlives the page

Picture a team chat app. A user opens it on a train, types a message, and the train enters a tunnel. The tab shows a grey dinosaur on reload, the message is lost, and when a colleague replies an hour later the user hears nothing because the tab was closed. Each of these problems has the same root: all of the app's code lives inside a page, and a page only runs while it is open and online.

A service worker is the web's answer. It is a JavaScript file that the browser registers once for your origin and then runs on its own, outside any tab. The browser wakes it up when something interesting happens: a page asks for a URL, a push message arrives, the network comes back. The worker handles the event and goes back to sleep.

This lesson is about the worker itself: what kind of program it is, every event it can receive, how it talks to pages, the rules that decide which pages it controls, the security model, how to debug it, and the hard limits that catch teams out. Caching strategies, precaching and Workbox are covered in the service worker caching lesson; here we look at the machine those strategies run on.

Three jobs no page can do

  • Answer requests when the page can't. Every request from a controlled page, including the navigation that loads the page itself, passes through the worker's fetch handler first. It can answer from a cache, rewrite the request, or go to the network. This is what lets an app open with no connection.
  • Run when no page is open. A push message from your server, a delayed upload after the network returns, or a click on a notification can wake the worker even if every tab is closed.
  • Coordinate between tabs. One worker serves every open tab of its scope, so it is a natural place for shared logic: one cache, one queue of pending writes, one place that decides when a new version takes over.
Two halves side by side. Left, without a service worker: the page sends its request straight to the network, the network is down, and the user gets the browser's offline error page. Right, with a service worker: the page's request goes to the worker first, and the worker can answer from its cache or go to the network, so the app still opens offline.without a workerPageNetworkoffline: the browser'serror page, app is gonewith a workerPageService workeryour code decidesCacheNetwork
The same request, two paths. With a worker in front, a dead network is one input to your code rather than the end of the story.

What it costs you

That power comes with a strict contract. The worker has no DOM and no window. It can't use synchronous APIs such as localStorage or synchronous XHR.

The browser may stop it at any moment when it is idle (Chrome stops an idle worker after about 30 seconds), so anything stored in a global variable can vanish between two events. And because a worker sits between your users and your server, a bad one can break the site for every returning user until it is replaced.

Teams that treat a service worker like a background page get bitten by exactly these rules: counters that reset, sessions that are forgotten, tabs that reload forever, and old versions that refuse to leave. The rest of the lesson builds the right model so none of that surprises you.

Check your understanding · Exercise 1

Which of these can a service worker do that ordinary page JavaScript can't? Select all that apply.

The mental model: a switchboard that sleeps

Picture a night switchboard operator in an old hotel. The operator sits in a back room, not in any guest's room. When a call comes in, a bell wakes them; they connect it and doze off again.

They keep notes in a ledger on the desk, because anything they only remember will be gone after a nap. And the manager can send them home at any time if the board has been quiet.

That is a service worker: the back room is its own thread, the bell is an event, the ledger is durable storage (the Cache API and IndexedDB), and the nap is the browser stopping the worker when it is idle. Keep this picture and most of the surprising behaviour in this lesson follows from it.

Where it runs

A service worker runs on a worker thread that belongs to the browser, not to any tab. Its global object is self, of type ServiceWorkerGlobalScope. Each registration has at most one active worker, shared by every tab, iframe and dedicated worker it controls. In Chromium it usually lives in the same renderer process as one of its pages, but that is a detail; what matters is that no page owns it.

A stack of three layers. Top: the clients, two tabs at /inbox and /settings and an iframe at /widget. Middle: one worker thread owned by the browser, running sw.js version 42, with no DOM. All three clients send their requests and messages to this one worker. Bottom: what the worker can reach: Cache Storage, IndexedDB, and the network.Clients: tabs, iframes, workersTab: /inboxTab: /settingsiframe: /widgetOne worker threadsw.js, version 42one copy, no DOMSurvives a stopCache StorageIndexedDBNetwork
Many clients, one worker. The worker sits on its own thread, and the only things that outlive it are in the bottom layer.
The worker gets the asynchronous web platform and nothing that blocks a thread or touches a page.
Available in the workerNot available
fetch(), Request, Response, Headers, streamswindow, document, any DOM node
caches (Cache API), indexedDBlocalStorage, sessionStorage
self.clients, self.registration, postMessageXMLHttpRequest (use fetch())
BroadcastChannel, crypto, TextEncoder, URLalert(), confirm(), synchronous APIs in general
importScripts() during the first run and installdynamic import() (not allowed in service workers)

Started for an event, stopped when idle

A registered worker is not running most of the time. When an event arrives and the worker is stopped, the browser starts it: it creates the thread, runs your script from the top (which registers the listeners), then dispatches the event. That cold start costs time: often tens of milliseconds on a laptop and more on a low-end phone. A running worker handles the event at once.

When the worker has had no events for a while, the browser stops it. Chrome and Firefox both use an idle timeout of about 30 seconds. Both also cap how long one event may keep the worker busy, at about five minutes, so a worker can't hold a thread forever. Safari doesn't publish its limits, so don't build on any exact number there.

A timeline from 0 to 125 seconds. Events arrive at 0 and 15 seconds (fetch) and 30 seconds (message); the worker runs and a global counter reaches 3. With no event for 30 seconds, the browser stops the worker at 60 seconds and the counter is gone. At 95 seconds a push event arrives: the browser cold-starts the worker, the script runs from the top, and the counter starts again at 1. The worker stops again at 125 seconds.eventsfetchfetchmsgpushworkerrunningstoppedcold startglobal variablehits 1hits 3gonehits 130 s idle0 s30 s60 s90 s120 s
The worker's life is a series of short runs, not one long one. After every stop, top-level code such as let hits = 0 runs again.

Two methods tell the browser the worker is still busy. event.waitUntil(promise) keeps it alive until the promise settles; event.respondWith(promise) on a fetch event does the same and also supplies the response. Work that is not inside one of these promises can be cut off halfway when the worker stops.

js
// sw.js
let hits = 0; // lost every time the worker stops

self.addEventListener("message", (event) => {
  hits += 1; // wrong: resets to 0 after ~30 s idle
  event.waitUntil(bumpCounter()); // right: lives in IndexedDB
});

self.addEventListener("push", (event) => {
  // without waitUntil the worker may stop before the notification shows
  event.waitUntil(self.registration.showNotification("New message"));
});
Globals are a cache that can be wiped at any moment. Durable state goes in IndexedDB or the Cache API, inside waitUntil.

Clock: 0 s. The browser stops the worker after 30 s with no event and no pending waitUntil.

The simulated worker at 0 seconds: worker stopped, global hits 0, IndexedDB hits 0, cold starts 0, uploads done/lost 0 / 0.stoppedworker0global hits0IndexedDB hits0cold starts0 / 0uploads done/lost
The global counter resets on every cold start; the IndexedDB counter keeps counting. An upload outside waitUntil dies when the worker is stopped.
  1. Send a few events, wait 40 s, and send another. Then try the upload with and without waitUntil.

Check your understanding · Exercise 2

A worker keeps the logged-in user's id in a global variable, set by a message from the page at startup. Users report that background uploads sometimes run with no user id. Why? Pick one.

Every event a worker can receive

A service worker script is mostly a list of addEventListener calls at the top level. Listeners must be added during the first run of the script, not later inside a promise or a timer: when the browser restarts the worker for an event, it only dispatches to listeners that exist once the script has finished its first pass.

The events fall into four families: lifecycle events move a new version into place, the fetch event makes the worker a proxy, and message connects it to pages. The rest let the worker run when no page is open, and that family is where browser support splits.

The events, what wakes them, and what they're for

Lifecycle, fetch and message work everywhere. Background work beyond push is a Chromium feature, so treat it as progressive enhancement.
EventFires whenTypical useSupport
installA new version is found and parsedPrecache the app shellAll engines
activateThe new version takes overDelete old caches, migrate dataAll engines
fetchA controlled client makes a requestAnswer from cache or networkAll engines
messageA page or worker calls postMessage on itCommands such as skip waiting or clear cacheAll engines
pushYour server sends a Web Push messageShow a notificationAll engines; on iOS only for home screen web apps (16.4+)
notificationclick, notificationcloseThe user acts on a notificationFocus or open the right tabWherever notifications work
syncConnectivity returns after registration.sync.register()Send queued writesChromium only
periodicsyncThe browser picks a moment, based on how often the user visitsRefresh content in the backgroundChromium only, installed apps
backgroundfetchsuccess and friendsA large download managed by the browser finishes or failsStore a podcast or filmChromium only

Chromium has a few more niche events, such as canmakepayment and paymentrequest for web payment apps. You will rarely meet them in a product, and never in an interview. The install and activate events, with skipWaiting() and clients.claim(), are covered step by step in the service worker caching lesson; this lesson only needs the fact that each version installs once and activates once.

A matrix of service worker events against Chromium, Firefox and Safari. install, activate, fetch, message, push and notificationclick work in all three; push in Safari on iOS needs version 16.4 or later and a web app added to the home screen. sync and backgroundfetch work only in Chromium. periodicsync works only in Chromium and only for installed apps.ChromiumFirefoxSafariinstall, activateyesyesyesfetchyesyesyesmessageyesyesyespushyesyesyes, iOS 16.4+notificationclickyesyesyessyncyesnonoperiodicsyncinstalled appnonobackgroundfetchyesnono
The top five rows work everywhere, so a design can rely on them. The bottom three are Chromium-only: use them to make things better where they exist, never as the only path.

The fetch event in detail

Every request from a controlled client fires fetch: the page's own navigation, its scripts, images, fonts, fetch() calls, EventSource streams, and cross-origin requests such as images from a CDN. The event carries the Request, the clientId of the tab that asked, and for navigations a resultingClientId for the page about to be created.

The handler has two choices. Call event.respondWith() with a promise for a Response, and the browser waits for it. Or return without calling it, and the browser fetches the request normally, as if there were no worker. The second choice is the right default for anything you don't want to handle: it is cheaper and avoids subtle bugs with streaming, ranges and credentials.

js
self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return;          // let the browser handle it
  if (new URL(request.url).origin !== location.origin) return;

  if (request.mode === "navigate") {
    // must be called synchronously, with a promise
    event.respondWith(fetch(request).catch(() => caches.match("/offline.html")));
  }
});
Opt in to the requests you handle; return early for the rest.
  • respondWith must be called synchronously. Calling it after an await throws InvalidStateError, because the browser has already moved on. Pass it a promise instead and do the async work inside.
  • A rejected promise is a network error. If the promise given to respondWith rejects, the page sees a failed request, exactly like being offline. A bug in a navigation handler means a blank error page for every user.
  • Not everything goes through fetch. WebSocket connections, requests from pages outside the scope, and the browser's own check for a new worker script never reach the handler.

Check your understanding · Exercise 3

A handler does const user = await getUser(); event.respondWith(fetchFor(user));. What happens? Pick one.

Talking to pages

A worker can't touch a page, and a page can't call a function in the worker. All they can do is send each other messages: structured copies of plain data (objects, arrays, strings, Blobs, ArrayBuffers) with optional transferable objects such as MessagePort. There are four ways to do it, and each fits a different shape of conversation.

From a page to its worker

A controlled page reaches its worker through navigator.serviceWorker.controller, a ServiceWorker object with a postMessage() method. The worker gets a message event; event.data holds the payload and event.source is the Client that sent it, so the worker knows exactly which tab to answer.

Watch for controller being null. It is null on the very first visit (the page loaded before the worker existed), after a hard reload with Shift (which bypasses the worker on purpose), and on any page outside the scope. Code that assumes it is set will crash on exactly the visits where you are least likely to be testing. When you need to reach the worker regardless, use (await navigator.serviceWorker.ready).active.

A message is a real event: if the worker is stopped, the browser starts it to deliver the message. The worker should wrap any async work in event.waitUntil() so the browser doesn't stop it halfway through.

Request and response with MessageChannel

postMessage is fire and forget. When the page needs an answer, such as "how many uploads are queued?", create a MessageChannel, keep one port, and send the other along with the message. The worker answers on that port, and only the asking page hears it. Wrapped in a promise, it feels like a function call.

js
// page
async function askWorker(message) {
  const reg = await navigator.serviceWorker.ready;
  const { port1, port2 } = new MessageChannel();
  const reply = new Promise((resolve) => (port1.onmessage = (e) => resolve(e.data)));
  reg.active.postMessage(message, [port2]);
  return reply;
}

// sw.js
self.addEventListener("message", (event) => {
  if (event.data.type !== "QUEUE_SIZE") return;
  event.waitUntil(countQueued().then((n) => event.ports[0].postMessage(n)));
});
The second port travels with the message as a transferable; the reply goes back only to the page that asked.
A sequence diagram between Page A, the worker and Page B. Page A posts a QUEUE_SIZE request to the worker with port2 of a new MessageChannel attached; the browser starts the worker if it was stopped. The worker answers 3 queued on that port, and only Page A hears it. Later Page A posts cache cleared on a BroadcastChannel, which Page B hears directly, and the worker only if it happens to be running. Finally the worker finds Page B with clients.matchAll and posts sync done to it.Page AWorkerPage BQUEUE_SIZE + port2started if it was stoppedport2: 3 queuedbroadcast: cache clearedhears it only if runningmatchAll, then sync done
A port gives a private reply to one asker. BroadcastChannel reaches every same-origin context but doesn't wake the worker. clients.matchAll() lets the worker choose who to tell.

From the worker to its pages: the Clients API

The worker finds its pages through self.clients. clients.matchAll({ type: "window" }) lists the tabs it controls (add includeUncontrolled: true for same-origin tabs it doesn't). clients.get(id) finds the tab behind a fetch event's clientId. Each Client has postMessage(); window clients add focus() and navigate(url).

clients.openWindow(url) opens a new tab, but browsers only allow it while handling a user gesture, which in practice means inside notificationclick. A worker can't pop up tabs on its own, which is a deliberate limit.

On the page side, listen with navigator.serviceWorker.addEventListener("message", ...). Messages sent while the page is still loading are held back until the document has been parsed, unless you call navigator.serviceWorker.startMessages() or assign onmessage. That avoids losing a message sent before your listener existed.

BroadcastChannel: one message, every context

new BroadcastChannel("app") gives every same-origin context that opens the same name a shared channel: tabs, iframes, workers and the service worker. It is the simplest way to tell every tab "the cache was cleared". One trap: posting to a channel does not start a stopped service worker, so the worker only hears it while it happens to be running. To reach the worker reliably, use postMessage on it.

Decision How should pages and the worker talk?

postMessage with event.source

Page posts to the controller; worker replies with event.source.postMessage().

Pros

  • Simple, works everywhere
  • Starts a stopped worker

Cons

  • No built-in way to match a reply to its request
  • Fails when controller is null

MessageChannel

Send a port with the request; the worker answers on it.

Pros

  • True request and response
  • The reply can't leak to other tabs

Cons

  • A little more code
  • One channel per request, or your own ids

BroadcastChannel

Everyone opens the same channel name and posts to it.

Pros

  • One line to reach every tab, including uncontrolled ones
  • Works without knowing client ids

Cons

  • Does not wake the worker
  • Every listener gets every message

clients.matchAll and postMessage

The worker lists its window clients and posts to each, or to one.

Pros

  • The worker decides who hears what
  • Can also focus or navigate a tab

Cons

  • Only the worker can start it
  • Needs the worker to be running for some event

What we pick: Use MessageChannel for questions a page asks the worker, clients.matchAll when the worker must tell specific tabs something (a sync finished, a new version is ready), and BroadcastChannel for tab-to-tab news that doesn't need the worker at all.

Check your understanding · Exercise 4

A settings page calls navigator.serviceWorker.controller.postMessage({ type: "CLEAR" }). It works in testing but throws for some users. Which visits fail? Select all that apply.

Scope: which pages a worker controls

The scope of a registration is a URL. A page is controlled by a worker when the page's URL starts with that scope. That is the whole rule, and it is literally a text prefix check, which has consequences people don't expect.

The default scope and the maximum scope

  • Default. If you don't pass a scope, it is the folder of the script: /sw.js gets /, and /app/sw.js gets /app/.
  • Maximum. You may pass a narrower scope than the default, but not a wider one. Registering /static/sw.js with scope: "/" is rejected with a SecurityError, because a file in /static/ shouldn't take over the whole site.
  • The escape hatch. If the response for the script carries Service-Worker-Allowed: /, the maximum scope becomes /. This is for builds that can't put the file at the root, such as a CDN path or a framework's output folder.
http
GET /build/sw.js
Service-Worker: script

HTTP/1.1 200 OK
Content-Type: text/javascript
Cache-Control: no-cache
Service-Worker-Allowed: /
The browser marks the request with Service-Worker: script; the server answers with the header that widens the allowed scope to the whole origin.

The header is a permission, not a scope. The page still has to ask for the wider scope in register("/build/sw.js", { scope: "/" }); without that, the default (/build/) still applies.

The prefix trap

Because matching is a plain prefix check, a scope of /app (no trailing slash) also controls /apple-pie, /app.html and /application/settings. Always end a folder scope with a slash: /app/. The default scope already does, which is one more reason to rely on it.

Several registrations on one origin

An origin can have many registrations, one per scope. A registration is identified by its scope, not its script: registering a different script URL for an existing scope replaces that registration's worker through the normal update flow. When scopes nest, the longest matching scope wins. A page at /app/admin/users with registrations for / and /app/ is controlled by the /app/ worker only; the root worker never sees its requests.

The match happens once, when the page (or iframe) is created. A tab keeps the same controller even if a closer scope is registered later, unless the new worker calls clients.claim(); otherwise the next navigation picks the new one. Same-origin iframes are matched by their own URL, so an iframe can have a different worker from its parent page.

A tree of pages on one origin with two registrations: /sw.js with scope / and /shop-sw.js with scope /shop, written without a trailing slash. The root page / is controlled by the root worker. Below it, /docs/intro is controlled by the root worker, /shop/cart by the shop worker as intended, and /shopping-guide also by the shop worker, by mistake, because its URL starts with the text /shop./root worker/docs/introroot worker/shop/cartshop worker/shopping-guideshop worker, trap/sw.js, scope //shop-sw.js, scope /shopfix: scope /shop/with a trailing slash
The longest matching prefix wins. Writing the scope as /shop/ would give /shopping-guide back to the root worker.
Three registrations checked against /shopping-guide. /sw.js with scope /: default scope of a root script, matches but a longer scope wins. /shop-sw.js with scope /shop: inside the script's folder, /, matches and controls the page. /build/sw.js with scope /admin/: rejected: SecurityError, outside /build/, not registered.scriptscoperesult for this page/sw.js/matches, loses/shop-sw.js/shopcontrols it/build/sw.js/admin/not registered
Matching is a plain text prefix check, and the longest matching scope wins. An invalid registration never exists, so it can't match anything.

/shopping-guide is controlled by /shop-sw.js (scope /shop), the longest of 2 matching scopes.

Check your understanding · Exercise 5

An origin registers /sw.js (scope /) and /shop-sw.js with scope: "/shop". Which worker controls a page at /shopping-guide? Pick one.

The security model

A service worker can read and rewrite every response a site's pages receive, and it keeps doing so after the tab is closed. An attacker who could install one would own the site for that user until the worker is replaced. So the browser puts more checks on registering a worker than on almost any other web feature.

What the browser checks before installing

  1. Secure context. The page must be served over HTTPS. localhost and 127.0.0.1 count as secure so you can develop without certificates. On plain HTTP, navigator.serviceWorker is simply missing.
  2. Same origin. The script URL must have the same origin as the page that registers it. A page on shop.example can't register cdn.example/sw.js, and no other site can register a worker for your origin.
  3. Scope within limits. The requested scope must sit inside the script's folder, or inside the path named by Service-Worker-Allowed.
  4. A JavaScript response. The script must come back with a JavaScript MIME type such as text/javascript. An HTML error page served as 200 is rejected rather than run.
  5. No redirects. The script request fails if the server redirects it, so a compromised redirect can't swap in another file.
  6. Content Security Policy. If the page has a CSP, the worker-src directive (falling back to child-src, then script-src, then default-src) must allow the script URL.
  7. It must parse and install. A syntax error, or an install handler whose waitUntil promise rejects, discards the new version.
A flowchart of five checks, top to bottom. Is the page on HTTPS or localhost? If not, the service worker API is missing. Is the script on the same origin as the page? If not, SecurityError. Is the requested scope inside the script's folder or the path allowed by Service-Worker-Allowed? If not, SecurityError. Is the response a JavaScript type with no redirect? If not, rejected. Does the script parse and does install succeed? If not, the new version is discarded and any old one stays. If every answer is yes, the version is installed.HTTPS or localhost?noAPI missingyesScript same origin as page?noSecurityErroryesScope inside the max scope?noSecurityErroryesJS type, no redirect?norejectedyesParses, install succeeds?noversion discardedyesInstalledthen waits or activates
Each check closes a way for someone else's code to become your worker. The last one is the check your own deploys most often fail.

No third-party workers

A third-party script on your page, say an analytics tag, runs with your origin, so it could register a worker on your origin if it can find a same-origin URL to point at. That is why user uploads and JSONP endpoints must never be served from your app's origin. A third-party iframe can register a worker for its own origin, but modern browsers partition storage by the top-level site, so a widget embedded on two sites gets two separate registrations and can't use them to follow a user around.

Rules that still apply inside the worker

  • CORS and the same-origin policy. The worker's fetch() follows the same rules as a page's. A cross-origin response without CORS headers is opaque: the worker can pass it to the page or store it, but can't read its body or status.
  • Its own CSP. The worker's policy comes from the headers on its script response, not from the page.
  • importScripts() is frozen after install. Scripts imported during the first run and install are stored with the version. Importing a new URL later throws, so a running worker can't pull in fresh code from anywhere.

Registrations don't last forever

Users can remove a worker by clearing site data, and a server can do the same by sending Clear-Site-Data: "storage", which unregisters workers along with other storage. Safari also deletes script-writable storage, including service worker registrations, for sites the user hasn't interacted with in seven days of browser use, unless the site was added to the home screen. Design so that losing the worker costs speed, never data.

Check your understanding · Exercise 6

Which of these registrations does the browser reject? Select all that apply.

The cost of a fetch handler, and the static routing API

A fetch handler is not free. Once a worker with a fetch listener controls a page, every request from that page has to wait for the worker. If the worker is stopped, which it often is when a user opens the site after a break, the browser must start a thread and run your script before the first byte of the navigation request can even leave the device.

Where the time goes

  • Worker startup. Tens of milliseconds on a fast laptop; on a low-end Android phone it can be a few hundred, more if the script is large or imports many files.
  • Your handler's JavaScript. Usually small, but it runs on one thread shared by every request from every tab.
  • The real work. A cache lookup or a network request, which you would pay anyway.

For requests the worker answers from the cache, startup is a good trade: a few dozen milliseconds to skip a network round trip. For requests the worker just passes to the network, it is pure overhead. Navigation preload (covered in the caching lesson) hides part of it by starting the network request in parallel. A cleaner fix is to tell the browser which requests the worker doesn't want at all.

A waterfall of one navigation on a mid-range phone, illustrative numbers. With no service worker: 300 milliseconds of network. With a fetch handler on a stopped worker: about 150 milliseconds to start the worker, 10 milliseconds of handler code, then the same 300 milliseconds of network, 460 in total. With a static route sending the request to the network: the browser skips the worker and it is 300 milliseconds again.no service workernetwork300 msfetch handler, worker was stoppedstartnetwork460 msstatic route: source networknetwork300 ms
When the worker only passes a request through, its startup is pure overhead. A static route, or no fetch handler at all, removes it. The numbers are illustrative; startup varies widely by device.

The static routing API

The static routing API lets a worker declare rules while it installs, with event.addRoutes(). The browser stores the rules and applies them itself, without starting the worker. A rule pairs a condition (a URL pattern, a request mode or destination, a method, or whether the worker is running) with a source: go straight to the network, read from cache, use the fetch-event as usual, or race-network-and-fetch-handler and take whichever answers first.

js
self.addEventListener("install", (event) => {
  if (!event.addRoutes) return; // feature check: not in every browser yet
  event.addRoutes([
    { condition: { urlPattern: "/api/*" }, source: "network" },
    { condition: { urlPattern: "/assets/*" }, source: "cache" },
    { condition: { requestMode: "navigate" }, source: "race-network-and-fetch-handler" },
  ]);
});
Rules are checked in order before the worker is involved. Anything that matches no rule still goes to the fetch handler.

Treat it as an optimisation, not a foundation. It shipped in Chrome 123 in March 2024, MDN's compatibility data lists Safari from version 27 (2026), and Firefox has not shipped it. So the same requests must still work through your fetch handler. The feature check above costs nothing where the API is missing.

Don't add a fetch handler you don't need

A worker that only handles push notifications should have no fetch listener at all; then the browser never routes requests through it. An empty listener, often left over from an old rule that installable apps needed one, adds startup cost for nothing. Since Chrome 115, Chrome detects a handler that does nothing, skips it, and logs a console warning, but the clean fix is to delete it.

Check your understanding · Exercise 7

A worker exists only to show push notifications, but someone added self.addEventListener("fetch", (e) => e.respondWith(fetch(e.request))). What changes for users? Select all that apply.

Debugging a service worker

Service worker bugs are hard to see because the worker runs in a different context from the page, starts and stops on its own schedule, and keeps old versions alive across reloads. Every major browser has tools for it; learning the Chrome ones well covers most cases.

Chrome: Application panel, Service workers pane

Open DevTools, choose Application, then Service workers. Each registration for the current origin shows its script, its status (for example #42 activated and is running), a waiting version if there is one, and the list of clients it controls.

A sketch of Chrome DevTools, Application panel. A sidebar lists Manifest, Service workers (selected), Storage, Cache storage and IndexedDB. The Service workers pane has three toggles at the top: Offline, Update on reload, and Bypass for network. Below, the registration for sw.js shows status number 42 activated and is running, with a Stop link, a second version number 43 waiting to activate, with a skipWaiting link, the clients /inbox and /settings, and buttons Update, Unregister, Push and Sync.DevTools: ApplicationManifestService workersStorageCache storageIndexedDBOfflineUpdate on reloadBypassSource: sw.js#42 activated, runningStop#43 waiting to activateskipWaitingClients: /inbox, /settingsUpdateUnregisterPushSyncPush sends a test payload; Sync fires a sync event
Everything you need is in one pane: the version that is running, the one that is waiting, who it controls, and buttons to fire events by hand.
  • Offline. Cuts the network for the page and the worker, so you can test the offline path without unplugging anything.
  • Update on reload. On every reload, fetches the worker script, installs it, and activates it without waiting. Great while editing the worker; dangerous to leave on, because it hides real update bugs that users will hit.
  • Bypass for network. Sends the page's requests straight to the network instead of through the worker's fetch handler. Use it to check whether a bug is in the worker or in the server.
  • Update, Unregister, Stop, Start. Force an update check, remove the registration, or stop a running worker to test that state really lives in storage.
  • Push, Sync, Periodic sync. Fire those events by hand, with a payload for push, so you don't need a real server message or a real network drop.

The rest of the toolbox

QuestionWhere to look
Did the worker answer this request?Network panel: the Size column says (ServiceWorker); requests the worker made itself show a gear icon.
What is in the cache?Application, Cache storage: each cache and its entries, with a delete button.
Why didn't my log appear?The worker logs to its own context: pick it in the Console's context menu, or click inspect next to the worker.
Which workers exist across all sites?chrome://serviceworker-internals: every registration with start, stop, unregister and inspect, plus an option to pause on worker startup.
How do I start clean?Application, Storage, Clear site data, or a fresh profile or private window.
Firefox and Safari?Firefox: about:debugging#/runtime/this-firefox. Safari: Develop menu, Service Workers.

A routine that finds most bugs

  1. Reproduce with Update on reload off, because that is what users have.
  2. Check which version is active and whether one is waiting. A waiting version explains most "my fix isn't live" reports.
  3. Click Stop, then repeat the action. If it now fails, you have state in a global.
  4. Tick Offline and reload. If the page breaks, the fallback path is wrong.
  5. Do a Shift reload. If the bug disappears, it is in the worker, since a hard reload bypasses it.

Check your understanding · Exercise 8

After a deploy, a developer's machine shows the new behaviour but QA's machines don't. The developer always has DevTools open. What is the likely explanation? Pick one.

Failure modes: stuck workers, reload loops, kill switches

Most production incidents with service workers fall into a few patterns. Each has a recognisable symptom, so it pays to know them before your first deploy rather than after it.

The worker that won't leave

Symptom: a fix is deployed, but some users keep the old behaviour for days. The usual causes are a new version that waits because the user never closes the last tab, an install that fails because one precached file returns 404 (so the old version stays in charge), or /sw.js cached by a CDN so the browser never sees the new bytes.

The caching lesson walks through update detection and the reload prompt; from a debugging angle, check the waiting slot first, then the Console of the worker for an install error.

The infinite reload loop

A common pattern is to reload the page when a new worker takes control, so the page and the worker run the same version. The page listens for controllerchange and calls location.reload(). Combined with a worker that calls skipWaiting(), that is fine once. It turns into a loop when every reload finds yet another "new" worker.

  • The script changes on every request. A server that injects a timestamp or a request id into sw.js makes every update check see a byte-different file, so every load installs, activates, and reloads.
  • Update on reload in DevTools. It installs a fresh worker on every reload by design; with an auto-reload listener the tab never settles.
  • First install with clients.claim(). Claiming fires controllerchange in a page that had no controller, causing a pointless reload on the very first visit.
A state machine forming a loop. The page loads with a controller. The update check finds sw.js byte-different, for example because of an injected timestamp. The new worker installs and, because of skipWaiting, activates and takes control. controllerchange fires in the page, the listener calls location.reload, and the page loads again, which starts the loop over. A guard arrow leaves the controllerchange state: if a reload flag is already set, or the page had no controller before, the page does not reload and the loop ends.Page loadshas a controllerupdate checksw.js differstimestamp insideinstallNew worker activeskipWaiting()takes controlcontrollerchangelistener firesreload()guard: flag setNo reloadloop ends
Every step is correct on its own; together they never stop. Fix the cause (a stable sw.js) and add the guard (reload at most once).
js
// page: reload at most once, and only for a real update
const hadController = Boolean(navigator.serviceWorker.controller);
let reloading = false;

navigator.serviceWorker.addEventListener("controllerchange", () => {
  if (!hadController || reloading) return; // first install, or already reloading
  reloading = true;
  location.reload();
});
Two guards: skip the first install, and never reload twice from one page. Better still, reload only after the user accepts an update prompt.

Errors inside handlers

  • A navigation handler that throws or rejects shows the browser's network error page, online or not. Always end a navigation strategy with a fallback, such as an offline page, and never let an exception escape respondWith.
  • Listeners added late. A push or fetch listener added inside a .then() is missing when the browser restarts the worker for an event, so the event is lost. Add every listener at the top level.
  • A promise that never settles inside waitUntil keeps the worker alive until the browser's limit of about five minutes, then the work is cut off. Put timeouts on network calls made in background events.

The kill switch

When a worker is badly broken, the fix is a self-removing worker at the same /sw.js URL, with no fetch handler. The caching lesson has the code and the rules that make it reach users (a stable script URL that nothing caches for long). From a debugging angle, one mistake is common enough to call out.

Check your understanding · Exercise 9

Users report the app reloads over and over after a release. The worker calls skipWaiting() and pages reload on controllerchange. What should you check first? Select all that apply.

What a worker can't do, real products, and the interview

Knowing the limits is what separates someone who has shipped a service worker from someone who has read about one. Each limit below follows from the model: an event-driven thread, owned by the browser, with no page and no guaranteed lifetime.

Things a service worker cannot do

  • Touch the page. No DOM, no window, no alert(). The only UI a worker can show by itself is a notification; everything else goes through a message to a page.
  • Run on its own schedule. There is no cron. setTimeout and setInterval die when the worker stops, and a worker only starts for an event. Periodic sync comes closest, but it is Chromium-only, needs an installed app, and the browser picks the times.
  • Hold a connection open in the background. A WebSocket opened in the worker closes when the worker is stopped. Real-time updates with no tab open need Web Push.
  • Use synchronous APIs. No localStorage, no synchronous XHR, no document.cookie. Cookies are reachable only through the asynchronous Cookie Store API, where the browser supports it.
  • Speed up or rescue the first visit. The worker installs during the first visit and controls pages from the next one (or after clients.claim()). A Shift reload, a page outside the scope, and another origin's pages are never intercepted.
  • See WebSocket traffic or bypass CORS. WebSockets don't fire fetch, and cross-origin responses without CORS stay opaque.
  • Keep data safe from eviction. The browser, the user, or Safari's seven-day rule can remove the registration and its caches. The server must stay the source of truth.
  • Run background work on iOS the way it does on Android. Background Sync, Periodic Sync and Background Fetch are not in Safari; push works only for web apps added to the home screen.

In real products

  • Offline-capable web apps such as Squoosh, Google's image compressor, use a worker to open with no network. Squoosh precaches the whole app, so after one visit it works entirely offline.
  • News and social PWAs, such as the original Twitter Lite, used a worker for an instant shell on repeat visits and for push notifications.
  • In-browser development tools such as StackBlitz use a worker in an unusual way. As StackBlitz describes its WebContainers, a worker answers the preview pane's requests from a server that runs inside the browser tab, so the preview looks like a normal website to the page inside it.
  • Push-only sites, such as many news publishers, register a tiny worker with only push and notificationclick listeners and no fetch handler at all, which is the right design when offline support isn't a goal.

Using it in a system design interview

Bring a service worker up when the prompt mentions offline use, flaky mobile networks, installable apps, push notifications, or fast repeat visits. Draw it as a box between the page and the network, with the Cache API and IndexedDB beside it, and one line to the push service. Then say three things: what events it handles, where its state lives (never in globals), and how a bad version gets replaced.

LevelWhat a strong answer adds
JuniorA worker can intercept requests and answer from a cache, works only on HTTPS, and is separate from the page.
MidEvent-driven lifetime and idle stops, state in IndexedDB, scope and why the script sits at the root, postMessage to pages.
SeniorStartup cost on navigations and how to avoid it, Chromium-only features as progressive enhancement, iOS limits, the kill-switch plan, and measuring version share in production.

Check your understanding · Exercise 10

A product manager wants the web app to check for new orders every 10 minutes and notify the user, even when no tab is open, on Android and iPhone. What do you propose? Pick one.

Summary

  • A service worker is an event-driven script the browser owns: it starts for an event, handles it, and is stopped after about 30 seconds idle, so nothing in a global variable can be trusted.
  • Durable state lives in IndexedDB or the Cache API, and any async work must sit inside event.waitUntil() or event.respondWith().
  • Lifecycle, fetch and message work everywhere; push works in all engines (on iOS only for home screen apps); sync, periodicsync and background fetch are Chromium-only.
  • Pages talk to the worker with postMessage and MessageChannel; the worker reaches pages with the Clients API; BroadcastChannel reaches every context but does not wake the worker.
  • Scope is a text prefix of the URL, defaults to the script's folder, can only be widened with Service-Worker-Allowed, and the longest matching scope wins.
  • Registration requires HTTPS, a same-origin script with a JavaScript MIME type and no redirect, so no third party can install a worker for your origin.
  • A fetch handler puts worker startup in front of every request; skip requests you don't handle, and use navigation preload or static routes to reduce the cost.
  • Debug with Update on reload off and the Stop button: a waiting version, state kept in a global, or a sw.js that changes on every request explains most service worker bugs.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What can a service worker do that page JavaScript can't, and what can't it do?

Question 2

A teammate stores the current user's auth token in a global variable in the service worker. What goes wrong and what do you suggest?

Question 3

The worker script is built to /static/js/sw.js. Why does it only control part of the site, and how do you fix it?

Question 4

How would a page ask the worker a question and wait for the answer?

Question 5

A site added a service worker only for push notifications, and its first navigation after a break got slower. Why, and what do you do?

Practice it

These design problems lean on service workers.

References

Read next