Service workers & offline apps
Learn how service workers intercept requests, manage their lifecycle, choose caching strategies, show offline fallbacks, and support push or background sync without registering one on this site.
- 01Follow the lifecycleSay what install, waiting, and activate mean, and what
skipWaiting()andclients.claim()do. - 02Pick a caching strategyChoose cache-first, network-first, stale-while-revalidate, or network-only for each kind of file or request.
- 03Plan for offlineShow an offline page when a page load fails, and say what push and sync need.
A helper between your page and the network
When a page needs a file, it sends a request to the network. The network sends back a response: the file itself.
A service worker is a JavaScript file that the browser runs on its own, apart from your page. It sits between your pages and the network. Every request from those pages reaches the service worker first.
The service worker can then:
- send the request on to the network, as usual;
- answer with a copy saved earlier in the cache (a store of saved responses on the user's device);
- answer with a saved "You are offline" page when there is no network;
- show a notification when your server sends a message (a push);
- try a failed job again later (a sync).
A service worker only looks after part of a site, called its scope. For example, the scope /app/ means every page whose address starts with /app/.
The experiments below only pretend. Some of them read what your browser supports, and nothing more. We never call navigator.serviceWorker.register() here. A real service worker stays in your browser after you leave, and it could take charge of this site on later visits.
Picture an office building where every delivery stops at the front desk first. The person at the desk can send the delivery on, hand over something already stored at the desk, or leave a note when deliveries can't get through.
- In real life: The building
- In JavaScript: The service worker’s scope
- In real life: Offices in the building
- In JavaScript: The pages and tabs it looks after
- In real life: Deliveries arriving
- In JavaScript: Requests the pages send
- In real life: The desk handing over something itself
- In JavaScript: An answer from the cache, or one the worker makes
This site does not register a service worker. These rows only read what your browser supports, once the page has loaded.
checkingA service worker uses ideas from earlier lessons: fetch, promises, IndexedDB, and the Cache API. With them, an app can keep working when the network is slow or gone.
Registration & lifecycle
INTERACTIVEYour page code tells the browser about the worker file. This is called registering it. After that, the worker goes through these stages, in order:
- installing: the worker gets ready, for example by saving files in the cache;
- installed, also called waiting: it is ready, but not in charge yet;
- activating: it clears out old things, such as old caches;
- activated: it is the active worker, ready to answer requests.
The worker in charge of a page is called that page's controller. When you publish a new version, it usually waits. It becomes active only after every tab that uses the old version is closed.
A well-run building doesn't swap the person at the desk halfway through a delivery. So the new person waits until the old shift ends, unless the building calls for a change right now.
- In real life: The new person learns the job
- In JavaScript: The install event
- In real life: The old person still serves open offices
- In JavaScript: The new worker is waiting
- In real life: The new person takes over the desk
- In JavaScript: The activate event
- In real life: Taking over right now
- In JavaScript: skipWaiting()
- In real life: Also serving offices that had nobody
- In JavaScript: clients.claim()
skipWaiting().register("/sw.js")install event: precache filesactivate event: cleanup old caches// Later: /sw.js changes on the servernew worker installsnew worker waits while old tabs are openskipWaiting(); // optional immediate handoffclients.claim(); // optional control for open pagesnonenonenonenone- No service worker is registered in this pretend scope.
This only pretends; no real worker is registered. Try the usual order first: Register v1, Finish v1 install, Publish v2, Finish v2 install, then Close all tabs.
skipWaiting() makes a waiting worker active now, and pages that used the old one switch to it. clients.claim() takes charge of open pages that had no worker.Service workers only work on secure https pages. The one exception is localhost, your own computer while you build.
By default, the scope is the folder the worker file is in. So /app/sw.js looks after /app/. The browser checks for a new version of the file each time you open a page in the scope. It also checks again if about 24 hours have passed since the last check.
if ("serviceWorker" in navigator && isSecureContext) { const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/app/", }); registration.addEventListener("updatefound", () => { const next = registration.installing; next?.addEventListener("statechange", () => { if (next.state === "installed" && navigator.serviceWorker.controller) { showUpdateReadyMessage(); } }); });}Line by line:
- Line 1 checks that the browser has service workers and that the page is secure.
- Lines 2–3 register
/sw.jsand ask for the scope/app/. - Line 6 listens for
updatefound. This event fires when the browser finds a new version. Line 7 gets the new worker while it installs. - Line 9 checks two things: the new worker is installed, and the page already has a controller. Then line 10 tells the user an update is ready.
A worker at /app/sw.js starts with the scope /app/. It does not look after the whole site. To ask for a wider scope, the server must allow it with a Service-Worker-Allowed header.
function defaultScope(scriptUrl) { const path = new URL(scriptUrl).pathname; return path.slice(0, path.lastIndexOf("/") + 1) || "/";} console.log(defaultScope("https://example.com/app/sw.js"));Line 2 takes the path from the address: /app/sw.js. Line 3 cuts it after the last /, which leaves the folder /app/.
When a new worker is installed and the page already has a controller, a new version is ready. Show the user an "Update ready" message. Don't change the page under the user without telling them.
This is also why you often need to refresh twice. The first refresh finds and installs the new worker. A later refresh, after the new worker is active, uses it.
function updateMessage(hasController, state) { if (state !== "installed" || !hasController) return "keep current worker"; return "show update ready";} console.log(updateMessage(true, "installed"));skipWaiting() makes the waiting worker active right away. Open pages that used the old worker switch to the new one. clients.claim() makes the active worker take charge of open pages that had no worker yet.
Both are useful for urgent fixes. But a page loaded with old code may then get new files, and the two may not fit together. An "Update ready" button that the user clicks is often easier to get right.
Answering requests
STEP THROUGHOnce a worker is active and in charge of a page, it gets a fetch event for each request the page makes inside the scope. The event holds the request in event.request.
To answer, the worker calls event.respondWith(). There is one strict rule: call it right away, inside the event handler, not later. The promise you pass to it can still wait for the cache or the network.
Choose a network setting, then step through the route a page load takes.
script
const request = event.request; if (request.mode === "navigate") { event.respondWith(networkFirst(request, "/offline.html")); return; } if (request.destination === "style" || request.destination === "script") { event.respondWith(cacheFirst(request)); return; } event.respondWith(staleWhileRevalidate(request));});self.addEventListener("fetch", (event) => { const request = event.request; if (request.mode === "navigate") { event.respondWith(networkFirst(request, "/offline.html")); return; } if (request.destination === "style" || request.destination === "script") { event.respondWith(cacheFirst(request)); return; } event.respondWith(staleWhileRevalidate(request));});Line by line:
- Line 1 listens for
fetchevents. Line 2 saves the request inrequest. - Line 4 checks for a page load (
"navigate"). Line 5 answers withnetworkFirst(), with/offline.htmlas the backup page. - Line 9 checks for CSS (
"style") or JavaScript ("script"). Line 10 answers withcacheFirst(). - Line 14 answers every other request with
staleWhileRevalidate().
The rule is about when you call respondWith(), not when the answer is ready. Calling it right away with a promise is fine. Calling it from a timer is too late. By then, the browser has already decided that this worker is not answering.
function responseDecision(isDelayed) { return isDelayed ? "late respondWith call" : "promise accepted immediately";} console.log(responseDecision(false));Notice the return after each respondWith(). Each request gets only one answer.
Notice what is missing, too. A service worker has no document and no page elements (no DOM). It can't change what the page shows directly.
Caching strategies
INTERACTIVEA caching strategy is a rule for where an answer comes from: the cache, the network, or both. There are four common ones:
- cache-first: use the saved copy. Go to the network only if there is no copy.
- network-first: ask the network. Use the saved copy only if the network fails.
- stale-while-revalidate: answer with the saved copy now, and fetch a new copy for next time. ("Stale" means a bit old; "revalidate" means check for a newer one.)
- network-only: always ask the network, and never use a saved copy.
The pantry is the cupboard at home; the shop is down the road. The hard part is not storing food. It is deciding when food from the pantry is still fine, and when you need it fresh from the shop.
- In real life: Take it from the pantry first
- In JavaScript: cache-first
- In real life: Call the shop first; use the pantry if the shop is closed
- In JavaScript: network-first
- In real life: Serve pantry food now, and buy more for next time
- In JavaScript: stale-while-revalidate
async function handleRequest(request, strategy) { if (strategy === "cache-first") return cacheFirst(request); if (strategy === "network-first") return networkFirst(request, "/offline.html"); if (strategy === "stale-while-revalidate") return staleWhileRevalidate(request); return fetch(request);}No request yet.
- Pick the settings, then click Request resource.
Pick a strategy, a network setting, and a kind of request (asset = a file such as CSS, navigation = a page load, api = data). This only pretends: it never touches your real browser cache.
/offline.html when the strategy can't get the page any other way.- App shell files with a version in their names: CSS, logo, main JavaScript
- A signed-in dashboard page that should be up to date, with
/offline.htmlwhen there is no network - Profile pictures that can be a little old while a new copy downloads
- A visit-counting message (analytics) that must not be answered from the cache
- A documentation site’s
/assets/app.ab12.css(ab12is the version) - News feed data that should show the newest items
- Small product pictures in a shop
- A payment, or booking an item in stock
Sort each file or request into the strategy that is safest for it.
Offline pages
A good offline app is planned. It does three things, one in each event:
- install: save a few key files in the cache ahead of time (this is called precaching). These are the app shell (the basic HTML, CSS, and JavaScript the app needs to open) and a simple
/offline.htmlpage. - activate: delete old caches, so old files don't pile up.
- fetch: send page loads to the network first. Show the offline page only when the network fails.
Use the offline page only for page loads, where the user needs something to read. A missing image can stay missing. A failed data request should stay an error, so the page can show a "Try again" button.
Be careful with answers from other sites. Your code often can't read them, so it can't check if they worked. Cache them only if you know where they come from and how long they stay correct.
const STATIC_CACHE = "app-shell-v3";const OLD_CACHES = ["app-shell-v1", "app-shell-v2"]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(STATIC_CACHE).then((cache) => cache.addAll(["/", "/styles.css", "/offline.html"]), ), );}); self.addEventListener("activate", (event) => { event.waitUntil( Promise.all(OLD_CACHES.map((name) => caches.delete(name))).then(() => self.clients.claim(), ), );});Line by line:
- Line 5:
event.waitUntil()tells the browser "don't finish this stage until this promise is done." - Lines 6–7 open the cache
app-shell-v3and save three files in it. - Line 14 deletes the old caches
app-shell-v1andapp-shell-v2. - Line 15 then calls
clients.claim(), so open pages with no worker get this one.
Cache names usually include a version, such as v3. When the worker activates, it deletes every old name and keeps the current one.
const currentCache = "app-shell-v3";const cacheNames = ["app-shell-v1", "app-shell-v2", "app-shell-v3"];const oldCaches = cacheNames.filter((name) => name !== currentCache); console.log(oldCaches.join(", "));app-shell-v3. The two older ones are deleted, so old files don't pile up.The browser can stop a service worker when it has nothing to do. Then its variables are gone. So save responses with the Cache API, and save lists of jobs in IndexedDB. Don't keep important work in a variable.
Push & background sync
Push and sync don't let your code run in the background whenever it likes. Each one needs extra setup.
A push needs all of these:
- a server that sends the push message;
- VAPID keys: a pair of keys that proves the push comes from your server;
- a subscription: the browser's address that your server sends pushes to;
- the user's permission to show notifications;
userVisibleOnly: true: a promise that every push shows the user a notification.
Background sync lets the browser run a named job again later, for example when the network comes back. It works mainly in Chrome-based browsers, such as Chrome and Edge. So check that the browser has it, and have a backup plan.
self.addEventListener("push", (event) => { event.waitUntil( self.registration.showNotification("New message", { body: event.data?.text() ?? "Open the app to read it.", }), );}); self.addEventListener("sync", (event) => { if (event.tag === "send-outbox") { event.waitUntil(sendQueuedMessagesFromIndexedDB()); }});Lines 1–7 show a notification when a push arrives. Lines 9–13 run the send-outbox job when the browser fires a sync event.
Sending data is different from reading it. If a message can't be sent yet:
- save the message before you try again;
- make sure the server handles a repeat safely, so sending it twice doesn't post it twice;
- remove it from the saved list only after the server says it got it.
The next code only shows the choice. It doesn't open a real IndexedDB list.
function saveForLater(online, messages) { return online ? "send now" : "save " + messages.length + " message(s) in IndexedDB";} console.log(saveForLater(false, ["tea"]));A common plan is an outbox: a saved list of messages waiting to be sent.
- Save the message in IndexedDB.
- Try to send it.
- If the network fails, register a sync job named
send-outbox. - When the sync event fires, read the saved messages and try again.
Where you’ll use this
Use a service worker when your app is still useful offline. Good examples are documentation, dashboards with saved data, apps people install on their phone, tools for work away from Wi-Fi, and forms that can send later.
Start small:
- cache the app shell;
- add an offline page;
- then pick a strategy for each kind of file or request.
While you build, open the Application panel in your browser's developer tools. The Service Workers part shows the worker file, its scope, its stage, and an update button. The Cache Storage part shows each cache name and the addresses saved in it. Test a normal online load first. Then tick the "Offline" box and test again.
Decide carefully what to cache. Never put one user's private data where another user could get it. Don't cache a payment result, a permission check, or a stock booking, because the old answer may be wrong now.
- Change the cache version and reload once. Check which worker is waiting.
- With an old tab still open, test the "Update ready" message.
- Close the old tab and reload. Check that the new cache has every file it needs.
Good defaults:
- Use cache-first for files with a version in their name.
- Use network-first for pages and for data that should be up to date.
- Use stale-while-revalidate for images and content that can be a little old.
- Use network-only for payments, visit counting, and permission checks, or save them in a list to send later.
- Use the Application panel to see workers, the pages they control, cache names, and an offline test.
- Before you call an app "ready for offline", test private data, redirects, answers from other sites, and a full cache.
Common misconceptions
The table compares a service worker with two other kinds of script. Read each row across to compare them.
| Question | Service worker | Web worker | Page script |
|---|---|---|---|
| What is its main job? | It catches requests, saves responses, and handles push and sync events. | It does slow, heavy work away from the page's main thread. | It reads and changes the page. |
| Can it change the page (the DOM)? | No. | No. | Yes. It runs the page. |
| How long does it run? | It starts for an event and can be stopped when it has nothing to do. | It runs while the page that made it keeps it. | It runs while the page is open. |
| Where should it keep data that must last? | In the Cache API and IndexedDB. | In IndexedDB, or send it to the page. | In the page, in memory, or in storage APIs. |
- "A registered worker controls every page." It only controls pages in its scope. Pages that were already open may need
clients.claim(). - "Installed means active." A new worker can be installed and still waiting.
- "I can call respondWith later." You must call it right away, inside the
fetchhandler. - "Variables keep my data safe." The browser can stop the worker, and its variables are lost. Use the Cache API or IndexedDB.
- "A worker can change the page." A service worker has no DOM. It can send messages to pages, or answer requests differently.
Practice exercises
4 EXERCISESWork out what the code prints, then type it in the answer box.
const events = [];
events.push("install v1");
events.push("activate v1");
events.push("install v2");
events.push("v2 waits");
console.log(events.join(" -> "));const events = [];
events.push("install v1");
events.push("activate v1");
events.push("install v2");
events.push("v2 waits");
console.log(events.join(" -> "));The array gets four strings, in order. Then join(" -> ") puts an arrow between each pair.
Choose the strategy for a CSS file with a version in its name.
const resource = "versioned CSS";
const strategy = resource === "versioned CSS" ? "cache-first" : "network-first";
console.log(strategy);const resource = "versioned CSS";
const strategy = resource === "versioned CSS" ? "cache-first" : "network-first";
console.log(strategy);A versioned file is safe to take from the cache first. When the file changes, it gets a new name, so the old copy is never used for the new file.
Explain why this handler doesn't work reliably. Then compare your idea with the solution.
self.addEventListener("fetch", (event) => {
setTimeout(() => {
event.respondWith(fetch(event.request));
}, 0);
});self.addEventListener("fetch", (event) => {
event.respondWith(fetch(event.request));
});respondWith() must be called right away, inside the fetch handler. Passing fetch(event.request) is fine: that promise can finish later.
Plan the strategies for a notes app. It should open offline, show recent notes when it can, and send edits later.
app shell: cache-first
HTML navigation: network-first + /offline.html
recent API data: network-first
avatars: stale-while-revalidate
analytics/payment: network-only
outbox writes: IndexedDB + optional background syncEach kind of request gets the strategy that fits how fresh it must be. Messages waiting to be sent go in IndexedDB, not in a variable inside the worker.
Quiz
8 QUESTIONSQuestion 1 of 8Which sentence best describes a service worker?
Choose an answer to see the explanation.
Question 2 of 8What does this lifecycle log print?
Read the code, then predictconst events = []; events.push("install v1"); events.push("activate v1"); events.push("install v2"); events.push("v2 waits"); console.log(events.join(" -> "));Choose an answer to see the explanation.
Question 3 of 8Which sentence about scope is true?
Choose an answer to see the explanation.
Question 4 of 8When must you call
respondWith()?Choose an answer to see the explanation.
Question 5 of 8Which strategy does this code pick for a versioned CSS file?
Read the code, then predictconst resource = "versioned CSS"; const strategy = resource === "versioned CSS" ? "cache-first" : "network-first"; console.log(strategy);Choose an answer to see the explanation.
Question 6 of 8Why shouldn't a service worker keep important data in variables?
Choose an answer to see the explanation.
Question 7 of 8What does a push notification need?
Choose an answer to see the explanation.
Question 8 of 8Which sentence about this site is true?
Choose an answer to see the explanation.
Key takeaways
- A service worker sits between the pages in its scope and the network. It wakes up for events and can be stopped when it has nothing to do.
- Install gets ready. Waiting keeps old tabs safe. Activate cleans up.
clients.claim()takes charge of open pages that had no worker. - Pick a strategy by how fresh the answer must be, how safe an old answer is, and how useful it is offline.
- Call
respondWith()right away. The promise you give it can do the slow work. - Push needs a server. Background sync needs a support check and data saved in IndexedDB.
Final definition.
A service worker is a browser script that looks after the pages in its scope. It can answer their requests from the cache, the network, or an offline page, so the app stays useful offline.
Up next: History & navigation.