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
fetchhandler 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.
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.
| Available in the worker | Not available |
|---|---|
fetch(), Request, Response, Headers, streams | window, document, any DOM node |
caches (Cache API), indexedDB | localStorage, sessionStorage |
self.clients, self.registration, postMessage | XMLHttpRequest (use fetch()) |
BroadcastChannel, crypto, TextEncoder, URL | alert(), confirm(), synchronous APIs in general |
importScripts() during the first run and install | dynamic 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.
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.
// 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"));
});
Clock: 0 s. The browser stops the worker after 30 s with no event and no pending waitUntil.
- 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
| Event | Fires when | Typical use | Support |
|---|---|---|---|
install | A new version is found and parsed | Precache the app shell | All engines |
activate | The new version takes over | Delete old caches, migrate data | All engines |
fetch | A controlled client makes a request | Answer from cache or network | All engines |
message | A page or worker calls postMessage on it | Commands such as skip waiting or clear cache | All engines |
push | Your server sends a Web Push message | Show a notification | All engines; on iOS only for home screen web apps (16.4+) |
notificationclick, notificationclose | The user acts on a notification | Focus or open the right tab | Wherever notifications work |
sync | Connectivity returns after registration.sync.register() | Send queued writes | Chromium only |
periodicsync | The browser picks a moment, based on how often the user visits | Refresh content in the background | Chromium only, installed apps |
backgroundfetchsuccess and friends | A large download managed by the browser finishes or fails | Store a podcast or film | Chromium 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.
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.
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")));
}
});
respondWithmust be called synchronously. Calling it after anawaitthrowsInvalidStateError, 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
respondWithrejects, 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.
// 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)));
});
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?
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.jsgets/, and/app/sw.jsgets/app/. - Maximum. You may pass a narrower scope than the default, but not a wider one. Registering
/static/sw.jswithscope: "/"is rejected with aSecurityError, 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.
GET /build/sw.js
Service-Worker: script
HTTP/1.1 200 OK
Content-Type: text/javascript
Cache-Control: no-cache
Service-Worker-Allowed: /
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.
/shop/ would give /shopping-guide back to the root worker./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
- Secure context. The page must be served over HTTPS.
localhostand127.0.0.1count as secure so you can develop without certificates. On plain HTTP,navigator.serviceWorkeris simply missing. - Same origin. The script URL must have the same origin as the page that registers it. A page on
shop.examplecan't registercdn.example/sw.js, and no other site can register a worker for your origin. - Scope within limits. The requested scope must sit inside the script's folder, or inside the path named by
Service-Worker-Allowed. - A JavaScript response. The script must come back with a JavaScript MIME type such as
text/javascript. An HTML error page served as200is rejected rather than run. - No redirects. The script request fails if the server redirects it, so a compromised redirect can't swap in another file.
- Content Security Policy. If the page has a CSP, the
worker-srcdirective (falling back tochild-src, thenscript-src, thendefault-src) must allow the script URL. - It must parse and install. A syntax error, or an
installhandler whosewaitUntilpromise rejects, discards the new version.
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.
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.
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" },
]);
});
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.
- 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
fetchhandler. 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
| Question | Where 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
- Reproduce with Update on reload off, because that is what users have.
- Check which version is active and whether one is waiting. A waiting version explains most "my fix isn't live" reports.
- Click Stop, then repeat the action. If it now fails, you have state in a global.
- Tick Offline and reload. If the page breaks, the fallback path is wrong.
- 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.jsmakes 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 firescontrollerchangein a page that had no controller, causing a pointless reload on the very first visit.
sw.js) and add the guard (reload at most once).// 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();
});
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
pushorfetchlistener 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
waitUntilkeeps 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, noalert(). 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.
setTimeoutandsetIntervaldie 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, nodocument.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
pushandnotificationclicklisteners and nofetchhandler 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.
| Level | What a strong answer adds |
|---|---|
| Junior | A worker can intercept requests and answer from a cache, works only on HTTPS, and is separate from the page. |
| Mid | Event-driven lifetime and idle stops, state in IndexedDB, scope and why the script sits at the root, postMessage to pages. |
| Senior | Startup 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()orevent.respondWith(). - Lifecycle,
fetchandmessagework everywhere; push works in all engines (on iOS only for home screen apps);sync,periodicsyncand background fetch are Chromium-only. - Pages talk to the worker with
postMessageandMessageChannel; the worker reaches pages with the Clients API;BroadcastChannelreaches 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.jsthat 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?
It can intercept every request from pages in its scope, including the navigation itself, so it can serve the app offline. It can run without any open tab for push messages, notification clicks and, in Chromium, background sync. And one instance serves every tab, so it can coordinate them.
It can't touch the DOM, use localStorage or other synchronous APIs, run on a timer, keep a WebSocket alive in the background, or intercept the first visit. The browser stops it when idle, so it can't hold state in memory.
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?
Chrome and Firefox stop an idle worker after about 30 seconds. The next event starts it again from the top of the script, and the global is empty, so background requests go out without a token or fail.
Keep durable state in IndexedDB and read it inside waitUntil. For auth, prefer HttpOnly cookies that the browser attaches to the worker's fetch() calls, so the worker doesn't need to hold the token at all.
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?
The default and maximum scope is the script's folder, /static/js/, so pages at / or /app/ aren't controlled. Registering with scope: "/" is rejected with a SecurityError.
Either serve the script from the root as /sw.js (simplest, and a stable URL helps updates), or have the server add Service-Worker-Allowed: / to the script response and register with scope: "/".
Question 4
How would a page ask the worker a question and wait for the answer?
Create a MessageChannel, post the request to (await navigator.serviceWorker.ready).active with port2 as a transferable, and resolve a promise when port1 receives the reply. The worker answers through event.ports[0] inside event.waitUntil().
Using ready.active instead of navigator.serviceWorker.controller also covers the first visit and Shift reloads, when the page isn't controlled.
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?
The worker also has a fetch handler, even a pass-through one, so every navigation must wait for the browser to start a stopped worker and run the handler before the request leaves. That startup can be tens of milliseconds on a laptop and a few hundred on a low-end phone.
Delete the fetch listener: a push-only worker doesn't need one, and then the browser never routes requests through it. If the worker does handle some requests, return early for the rest, turn on navigation preload, and add static routes with event.addRoutes() where the browser supports them.
Practice it
These design problems lean on service workers.
References
- Service Worker API MDN overview of the API, events and interfaces.
- Service Workers specification The W3C spec: scope matching, the update algorithm, and the static routing API.
- The service worker lifecycle web.dev article on install, waiting, activate and updates.
- Clients interface MDN reference for matchAll, get, openWindow and claim.
- Use the Static Routing API to bypass the service worker for specific paths Chrome's introduction to addRoutes.
- Debug Progressive Web Apps Chrome DevTools guide to the Application panel.
Read next
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Progressive web appsA website that the browser can install as an app: a manifest gives it a name, icons, and its own window, a service worker makes it open without the network, and the result lives on the home screen without an app store.
- Offline-first designA way of building apps where the device's own copy of the data is what the UI reads and writes, and a sync engine reconciles it with the server in the background, so the app stays fast and usable on a bad connection or none at all.
- Background sync and queued writesSaving the user's writes in a durable queue when the network is gone, then sending each one exactly once when it comes back, even if the tab was closed: the write half of every offline-capable app.
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.