Service Workers & Offline Caching
Open most websites with the network switched off and you see the browser's "No internet" page. Apps such as online shops, news readers and train-ticket sites often do better: they still open, show what was loaded last time, and tell you politely that you are offline. The tool behind this is a service worker.
A service worker is a JavaScript file that the browser runs separately from your pages. Once installed, it sits between your site and the network, and every request your pages make (for HTML, CSS, scripts, images and API data) passes through its fetch event. It can answer from a local cache, go to the network, or combine both. In this lesson you will learn the service worker lifecycle, store responses with the Cache API, write the three most common caching strategies, and understand the HTTP cache headers that work alongside them.
What a service worker can and cannot do
A service worker is a special kind of Web Worker:
- It has no DOM access. It talks to pages with
postMessage. - It is event driven. The browser starts it when an event arrives (a request, a push message) and stops it when idle, so you cannot keep data in global variables between events. Store data in the Cache API or IndexedDB.
- It works only on HTTPS (and on
http://localhostfor development), because a script that can rewrite every response must not be injected by an attacker on the network. - It controls pages inside its scope: by default, the folder its file lives in and everything below. A worker at
/sw.jscan control the whole site; one at/js/sw.jscontrols only pages under/js/.
Registering one takes a single call from your page. Always check that the browser supports it first:
// main.js, loaded by every page
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service worker registered for", registration.scope);
} catch (error) {
console.error("Service worker failed to register:", error);
}
});
}Waiting for load means the service worker's own downloads do not compete with the first visit to your page.
The lifecycle
A service worker goes through fixed stages. Understanding them prevents most "why is my old code still running?" confusion:
| Stage | What happens | Typical code |
|---|---|---|
| Install | The browser downloads the new file and fires install once | Precache the files the site needs |
| Waiting | A new version is installed but an older one still controls open tabs | Nothing; it waits for those tabs to close |
| Activate | The new version takes over and fires activate | Delete old caches |
| Fetch | Every request from controlled pages fires fetch | Answer from the cache or the network |
The browser checks for a new version when the user visits your pages; if sw.js has changed by even one byte, the new version installs. By default it then waits until every tab using the old version is closed, so one page never mixes two versions of your code. A service worker can skip this wait by calling self.skipWaiting() during install, but only do that when the new code works with pages loaded by the old one.
event.waitUntil(promise) tells the browser "this stage is not finished until my promise settles". If the promise rejects during install, the new version is thrown away and the old one keeps working, which is exactly what you want when a download fails.
Storing responses with the Cache API
The Cache API stores pairs of a request (usually a URL) and a Response. You can open named caches with caches.open(name), then put(), match(), keys() and delete() entries. It is available in service workers and in normal pages, so you can try it right here:
await caches.delete("menu-v1"); // start fresh on every run
const cache = await caches.open("menu-v1");
// Store a response under a URL, as a service worker would
const menu = { dish: "Masala dosa", price: 120 };
await cache.put("/api/menu", new Response(JSON.stringify(menu), {
headers: { "Content-Type": "application/json" },
}));
// Look it up later, even with no network
const hit = await cache.match("/api/menu");
const data = await hit.json();
console.log(`${data.dish}: ₹${data.price}`);
console.log(await cache.match("/api/offers")); // nothing stored
const keys = await cache.keys();
console.log(keys.map((request) => new URL(request.url).pathname));
console.log(await caches.has("menu-v1"));Masala dosa: ₹120 undefined [ '/api/menu' ] true
match() gives back a real Response, so you read it with .json() or .text() exactly like a fetch() result. A miss gives undefined, not an error. Unlike the browser's normal HTTP cache, nothing in the Cache API expires by itself: entries stay until your code deletes them.
A complete offline-ready service worker
Here is a small but realistic sw.js for a site. It precaches the "app shell" (the files every page needs), cleans up old caches on activation, and answers requests with a different strategy for pages and for static files:
// sw.js, at the root of the site
const VERSION = "v3";
const SHELL_CACHE = `shell-${VERSION}`;
const SHELL_FILES = ["/", "/offline.html", "/css/site.css", "/js/main.js", "/img/logo.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_FILES)),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
for (const name of await caches.keys()) {
if (name !== SHELL_CACHE) await caches.delete(name); // remove old versions
}
})());
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return; // let POST and others go to the network as usual
if (request.mode === "navigate") {
// Pages: network first, so users see fresh content; offline page as a fallback
event.respondWith(
fetch(request).catch(async () => (await caches.match(request)) ?? caches.match("/offline.html")),
);
return;
}
// Static files: cache first, network if missing
event.respondWith(
caches.match(request).then((cached) => cached ?? fetch(request)),
);
});cache.addAll() downloads every file and fails as a whole if any one fails, so a half-filled cache is never used. event.respondWith() gives the browser your answer; if a fetch handler does not call it, the request goes to the network normally. Changing VERSION changes the file, which installs the new worker, fills a new cache, and lets activate delete the old one.
Choosing a caching strategy
There is no single right strategy; choose per type of request:
| Strategy | How it works | Good for |
|---|---|---|
| Cache first | Use the cache; go to the network only if missing | Versioned CSS, JS, fonts, logos |
| Network first | Try the network; use the cache if offline | HTML pages, account data, prices |
| Stale-while-revalidate | Answer from the cache at once, refresh the cache in the background | News lists, menus, profiles that may be a little old |
| Network only | Never cache | Payments, login, anything private or real-time |
Stale-while-revalidate is popular because it feels instant and stays reasonably fresh. You can try it in a page with the Cache API and a real request:
await caches.delete("api-v1");
// Stale-while-revalidate: answer from the cache at once, refresh it in the background
async function cachedJson(url) {
const cache = await caches.open("api-v1");
const cached = await cache.match(url);
const refresh = fetch(url).then(async (response) => {
if (response.ok) await cache.put(url, response.clone()); // a body can be read only once, so store a copy
return response;
});
if (cached) {
refresh.catch(() => {}); // offline? keep showing the cached copy
return { source: "cache", data: await cached.json() };
}
const response = await refresh;
return { source: "network", data: await response.json() };
}
const url = "https://jsonplaceholder.typicode.com/users/1";
const first = await cachedJson(url);
console.log(first.source, first.data.name);
await new Promise((resolve) => setTimeout(resolve, 500)); // let the cache write finish
const second = await cachedJson(url);
console.log(second.source, second.data.name);network Leanne Graham cache Leanne Graham
The first call has nothing cached, so it waits for the network. The second answers straight from the cache while a fresh copy downloads for next time. Note response.clone(): a response body can be read only once, so you store one copy and return the other.
HTTP caching works alongside
Before a request even reaches the network, the browser checks its own HTTP cache, controlled by headers your server sends. A service worker does not replace these headers; good sites use both.
| Header | Meaning |
|---|---|
Cache-Control: max-age=31536000, immutable | Keep for a year without asking again. Use only for files whose name changes when their content changes, such as app.4f9a1c.js from Vite. |
Cache-Control: no-cache | You may store it, but check with the server before each use. Good for HTML. |
Cache-Control: no-store | Never store it. Use for private data such as bank statements. |
ETag | A version tag; the browser sends it back and the server can answer 304 Not Modified with no body. |
This is why build tools put a content hash in file names: the HTML is always checked, and it points to file names that can be cached forever because new content always gets a new name.
Common mistakes
- Caching API responses forever with "cache first". Prices and stock change. Use network first or stale-while-revalidate for data.
- Never deleting old caches. Version your cache names and clean up in
activate. - Caching private or POST requests. Skip non-GET requests, and never cache pages that show one user's private data on a shared computer.
- Putting
sw.jsin a subfolder and wondering why it does not control the whole site. Its location sets its scope. - Calling
skipWaiting()without thinking. Old pages may then receive files from the new version and break.
Key points
- A service worker runs separately from pages, on HTTPS only, and intercepts requests from pages in its scope through the
fetchevent. - Lifecycle: install (precache), waiting, activate (clean old caches), then fetch;
event.waitUntil()extends each stage. - The Cache API stores request/response pairs under names you choose; entries never expire by themselves.
- Use cache first for hashed static files, network first for pages and changing data, stale-while-revalidate for data that may be slightly old, and network only for private or real-time requests.
- HTTP
Cache-Controlheaders and content-hashed file names work together with service workers.
Exercise
Write the fetch handler of a service worker for a cricket score site. Pages should use network first with an /offline.html fallback, requests to /api/scores must never be cached, and images under /img/ should use cache first, saving new images into a cache called images-v1.
Show solution
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== "GET") return;
if (url.pathname.startsWith("/api/scores")) return; // network only: live data
if (request.mode === "navigate") {
event.respondWith(fetch(request).catch(() => caches.match("/offline.html")));
return;
}
if (url.pathname.startsWith("/img/")) {
event.respondWith((async () => {
const cache = await caches.open("images-v1");
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
})());
}
});Returning without calling respondWith() lets the request go to the network normally, which gives live scores network-only behaviour. Images are saved only when the response is OK, so error pages are never cached.
Quick Quiz
1. Why do service workers require HTTPS (except on localhost)?
A script that controls all requests is very powerful, so browsers allow it only over secure connections.
2. A new version of sw.js is installed while the old one controls two open tabs. What happens by default?
The waiting stage stops one page from mixing files from two versions of your site.
3. Which strategy fits a versioned file like app.4f9a1c.js?
A content-hashed file never changes under the same name, so serving it from the cache is always safe.
4. Why does stale-while-revalidate call response.clone() before saving?
Reading the body consumes it, so you store a clone and use the original.
Learn JavaScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
