Lesson 35 of 37
Service Workers and Offline
A background script that intercepts network requests — the piece that makes a web app work offline, feel instant on repeat visits, and handle background sync and push notifications.
Earlier in the track, the "fetching data" lesson put your JavaScript
and the network in a straight line: call fetch, wait for the server,
show the result. A service worker is the thing that lets you put
something else between those two steps — a script the browser runs
in the background, invisible to the user, that can look at each
outgoing request and decide what to do: pass it through, serve a
cached response, try the cache first and fall back to the network,
try the network and fall back to the cache, or refuse it entirely.
That single ability is what makes offline support work, what makes "opens instantly even on a slow connection" work, and what powers push notifications and background sync.
How a service worker fits into a page
A service worker has a few properties that make it unlike any other script on the page:
- It has no DOM. It can't read or write the HTML. It only sees network traffic.
- It's one worker per scope (usually one per origin), not one per page. Opening three tabs of the same site shares one worker.
- It keeps running between page loads, including after every tab is closed, which is what lets push notifications still arrive when the site isn't open.
- It intercepts fetches from any page under its scope — the pages don't know the worker is there.
Registering one from a page looks like this:
// index.html / index.js
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}And the worker itself, in /sw.js:
// sw.js — runs in its own thread, no DOM, intercepts fetches
self.addEventListener("install", (event) => {
// runs once when the worker is new
});
self.addEventListener("activate", (event) => {
// runs once when the worker takes over
});
self.addEventListener("fetch", (event) => {
// runs on every network request the page makes
});The install-wait-activate lifecycle
The single thing most new service-worker code gets wrong is the
lifecycle. When you deploy a new sw.js, the browser doesn't just
swap it in. It does this:
- Step 1
1. Install
Browser downloads the new sw.js, parses it, runs its `install` handler. At this point, the new worker exists but the old worker is still in control of every open tab.
- Step 2
2. Wait
The new worker sits in a `waiting` state. It stays there until every tab controlled by the old worker is closed. This is the default — the idea is to avoid a mid-session swap that could break something.
- Step 3
3. Activate
Once the last old tab closes, the new worker's `activate` handler runs, it takes over, and future tabs are controlled by it.
In practice, that "wait" step is why so many deployments feel like "the fix shipped but users still see the old version." Two opt-in escape hatches are standard:
self.addEventListener("install", (event) => {
self.skipWaiting(); // don't wait for old tabs to close
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim()); // take over existing tabs immediately
});Adding both means a new deploy takes effect as soon as it's installed, including on pages that are already open. It's not default behaviour because an instantaneous swap can genuinely break a running session (code on the page is suddenly talking to a different fetch interceptor). Add it deliberately when you've checked that your app is robust to it.
Caching: strategies, not one rule
The Cache API gives you named, persistent Cache objects you can
store Request → Response pairs in, keyed by URL. The question is
when to read from it and when to write to it. The common
strategies are:
- Cache first. Try the cache; if nothing's there, go to the network and cache that. Good for immutable assets (hashed bundles, fonts, images with content hashes). Fast and offline-safe.
- Network first. Try the network; if that fails or times out, fall back to the cache. Good for the main HTML document where freshness is more important than speed.
- Stale-while-revalidate. Serve from cache immediately, start a network request in the background, and update the cache with the response. Good for API data where "fast and eventually fresh" is the right trade. This is the strategy the SWR npm library is named after.
- Cache only. Serve from cache or fail. Good for assets you've pre-cached during install and know are always available.
- Network only. Pass the request straight through. Good for requests that must not be cached (authenticated writes).
A single service worker usually uses a mix — cache-first for
static assets, stale-while-revalidate for the API, network-first
for the HTML shell, network-only for POSTs.
A minimal cache-first handler looks like this:
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open("v1").then((cache) => cache.addAll([
"/",
"/offline.html",
"/static/app.a1b2c3.js",
"/static/app.d4e5f6.css",
])),
);
});
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((hit) => hit ?? fetch(event.request)),
);
});That's the minimal shape. Real projects usually reach for Workbox, Google's library of strategy implementations, so you don't write cache plumbing by hand.
Pre-caching, and the scope rule
Two details that cause real deploys real grief if you don't know them:
- Pre-caching during install: the
installhandler is your one chance to pre-load a list of assets so the app works offline on first visit.cache.addAll(urls)fetches all of them and refuses to install the worker if any one fails, which means you can trust the cache is complete when the activate handler runs. - Scope: a service worker at
/app/sw.jsonly controls pages under/app/. A worker at/sw.jscontrols the whole origin. Register the worker at the highest path you want it to control.
Scope also controls where the worker can intercept fetches. A
worker at /app/sw.js can't see requests from /other/page.
When not to reach for a service worker
Service workers add complexity: deployment, debugging, cache invalidation. They earn their cost when:
- The app needs to work offline.
- The app needs feel-instant repeat loads (an inbox, a dashboard, a news reader).
- You want push notifications or background sync.
They are overkill when:
- The site is static enough that HTTP cache headers already do the job.
- The site is small enough that the regular network path is already fast.
- There's no offline use case to speak of.
A content-hashed bundle plus Cache-Control: immutable headers on
the CDN already gives you "repeat loads are instant" without a
single line of service-worker code. Reach for a worker when you
need something those tools can't do: offline, interception,
notifications, or sync.
Debugging, briefly
Service workers are notoriously annoying to debug because the lifecycle hides the exact version running. The browser DevTools help:
- Application → Service Workers shows the worker's status, lets you "Update on reload" (always re-registers the worker on page load, so your edits take effect), and lets you manually "Unregister" to start clean.
- Application → Cache Storage shows the actual bytes in each named cache.
- Network tab flags whether a given response came from the worker ("from ServiceWorker") vs. the network vs. the HTTP cache.
The one habit that saves hours: during development, enable "Update on reload" in the Service Workers panel. It bypasses the install- wait-activate dance for your own tab and makes iterating on the worker feel like iterating on any other file.
What to remember
- A service worker is a script the browser runs in the background between your pages and the network. It intercepts fetches, has no DOM, and keeps running when the tab is closed.
- Lifecycle: install → wait (until all old tabs close) → activate.
self.skipWaiting()+clients.claim()opts into an immediate takeover; both are opt-in because an instant swap can break a running session. - Cache strategies are a toolkit, not one rule. Cache-first for immutable assets, stale-while-revalidate for API data, network-first for the HTML shell, network-only for writes.
- Pre-cache a known list of assets in the install handler with
cache.addAllso the app works offline from the first visit. - Scope is controlled by where the worker file lives.
/sw.jscontrols the whole origin;/app/sw.jsonly controls/app/. - Reach for a service worker when you need offline, instant repeat loads, push, or background sync. For simpler cases, HTTP cache headers on content-hashed assets already do the job.
- DevTools → Application → Service Workers is where you debug. "Update on reload" during dev is the one habit that saves hours.
Check yourself
4 questions · pass 3/4 to unlock Testing JavaScript
1.What is a service worker, in one sentence?
2.A fresh version of your service worker file (
sw.js) is deployed. What does the browser actually do?3.Which of these is a sound cache strategy for a static asset like a hashed JavaScript bundle (
/static/app.a1b2c3.js)?4.What's a sensible cache strategy for an API response like
/api/notificationsthat needs to feel fast but also needs to be eventually up-to-date?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.