Service Workers and PWAs
Reviewed & published by Brayan K
A service worker is a background script that the browser runs separately from your web page, acting as a programmable network proxy that can cache responses and serve your app offline — the foundation of Progressive Web Apps (PWAs).
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Registration & navigator.serviceWorker
- Lifecycle: install, activate, fetch
- The Cache API
- Offline-first strategies
- Web app manifest & installability
- Scope, HTTPS & vs Web Workers
💡 Running Code Locally: Service workers require a secure context and a real server. The snippets below are illustrative. For the best experience:
- Serve your site over HTTPS (or http://localhost during development)
- Use your browser's DevTools Application tab to inspect service workers and caches
- Download Node.js to run a local dev server
📮 Real-World Analogy: A service worker is like a helpful mailroom clerk for your app:
- • Network proxy = every request passes through the clerk first
- • Cache = a filing cabinet of saved copies the clerk can hand back instantly
- • Offline = even if the post office (network) is closed, the clerk serves from the cabinet
- • install/activate = the clerk stocks the cabinet, then takes over the desk
Service workers are the engine behind Progressive Web Apps: web apps that load fast, work offline, and can be installed to the home screen like native apps. A service worker runs in its own thread, has no access to the DOM, and stays event-driven — the browser wakes it to handle events like install, activate, and fetch, then lets it sleep. Because it can intercept every network request, it must be served over HTTPS.
| Lifecycle Event | When It Fires | Typical Use |
|---|---|---|
| install | First registration / new version | Pre-cache the app shell |
| activate | After install, when taking control | Clean up old caches |
| fetch | On every network request in scope | Serve from cache or network |
⚠️ Before you press Run: most of the snippets in this lesson are the real thing — code that belongs in sw.js on a real server. The editor has no self, no install event and no caches for them to hook into, so they print nothing when you run them. Read those for the shape of the API. The three exercises further down (the mini service worker, Your Turn, and the mini-challenge) are self-contained simulations that do run and print, so you can watch the caching decisions play out.
🔥 Registering a Service Worker
From your page, register the worker with navigator.serviceWorker.register(). Always feature-detect first, and register after load so it does not compete with critical page resources.
// Runs in the page (not the worker)
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js")
.then((reg) => {
console.log("SW registered. Scope:", reg.scope);
})
.catch((err) => {
console.error("SW registration failed:", err);
});
});
} else {
console.log("Service workers not supported in this browser");
}The returned registration's scope tells you which pages this worker controls — by default, the folder the script lives in and everything beneath it.
🔥 The Install Event: Pre-Caching
Inside the worker (sw.js), the install event is where you open a cache and store the core files. Wrap async work in event.waitUntil() so the browser knows installation is not finished until caching completes.
// This code lives inside sw.js
const CACHE_NAME = "app-shell-v1";
const APP_SHELL = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/offline.html"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log("Pre-caching app shell");
return cache.addAll(APP_SHELL);
})
);
});cache.addAll() fetches and stores every URL in the array. If any request fails, installation fails — so keep the pre-cache list to essential, reliable assets.
🔥 The Activate Event: Cleaning Old Caches
When a new worker activates, old caches from previous versions should be deleted. The activate event is the right place, because the previous worker is no longer in control.
// Inside sw.js
const CACHE_NAME = "app-shell-v1";
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((oldKey) => {
console.log("Deleting old cache:", oldKey);
return caches.delete(oldKey);
})
)
)
);
});Bumping the cache name (for example to app-shell-v2) on each release lets the activate handler automatically purge stale assets.
🔥 The Fetch Event and Cache API
The fetch event lets the worker intercept requests. With event.respondWith() you decide the response — often by checking the cache first via caches.match().
// Inside sw.js - cache-first
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
// Return cached response if we have it
if (cached) {
return cached;
}
// Otherwise fetch from the network
return fetch(event.request);
})
);
});Cache-first is great for static assets that rarely change. It returns instantly from storage and only touches the network on a miss.
Worked example: a service worker you can actually run
The snippet above is correct but silent — the editor cannot fire an install event at it. So here is the same strategy wired to a miniature, synchronous stand-in for caches and fetch. The plumbing is fake; every decision your worker makes is real. Run it, then read the two blocks of output side by side: the same five requests, once with a network and once without.
// ---- Stand-ins for the browser APIs, so this runs right here ---------
// The real Cache API is asynchronous and only exists inside a service
// worker. These synchronous stand-ins keep the DECISION logic identical
// while letting the whole story print in order.
const CACHE_NAME = 'app-shell-v1';
const cacheStorage = { // stands in for the global 'caches'
stores: {},
open(name) {
if (!this.stores[name]) this.stores[name] = {};
const store = this.stores[name];
return {
addAll(urls) { urls.forEach(u => { store[u] = 'cached ' + u; }); },
put(url, body) { store[url] = body; }
};
},
match(url) { // caches.match: the body, or undefined
for (const name in this.stores) {
if (url in this.stores[name]) return this.stores[name][url];
}
return undefined; // a cache MISS
}
};
let networkUp = true; // flip this to simulate aeroplane mode
function fakeFetch(url) {
if (!networkUp) throw new Error('network unreachable');
return 'fresh ' + url;
}
// ---- install: pre-cache the app shell --------------------------------
const APP_SHELL = ['/', '/styles.css', '/app.js', '/offline.html'];
cacheStorage.open(CACHE_NAME).addAll(APP_SHELL);
console.log('install: pre-cached ' + APP_SHELL.length + ' files into ' + CACHE_NAME);
// ---- fetch: the cache-first strategy ---------------------------------
function onFetch(url) {
const cached = cacheStorage.match(url); // caches.match(event.request)
if (cached) {
return 'CACHE | ' + cached; // hit: instant, works offline
}
try {
const fresh = fakeFetch(url); // miss: ask the network
cacheStorage.open('runtime').put(url, fresh);// and keep a copy for next time
return 'NETWORK | ' + fresh;
} catch (err) {
return 'OFFLINE | ' + cacheStorage.match('/offline.html');
}
}
// ---- Drive it: a first visit, then the same app with no signal -------
console.log('--- online ---');
for (const url of ['/', '/styles.css', '/api/news', '/api/news', '/logo.png']) {
console.log(url + ' -> ' + onFetch(url));
}
networkUp = false;
console.log('--- offline (aeroplane mode) ---');
for (const url of ['/', '/api/news', '/help/never-visited.html']) {
console.log(url + ' -> ' + onFetch(url));
}
// ✅ Expected output:
// install: pre-cached 4 files into app-shell-v1
// --- online ---
// / -> CACHE | cached /
// /styles.css -> CACHE | cached /styles.css
// /api/news -> NETWORK | fresh /api/news
// /api/news -> CACHE | fresh /api/news
// /logo.png -> NETWORK | fresh /logo.png
// --- offline (aeroplane mode) ---
// / -> CACHE | cached /
// /api/news -> CACHE | fresh /api/news
// /help/never-visited.html -> OFFLINE | cached /offline.html
//
// Two things to notice. First, the app still works offline — that is the
// whole point. Second, look at the fourth line: the SECOND request for
// /api/news came from the cache while the network was perfectly healthy.
// Cache-first serves stale news. That is exactly the bug the next section
// fixes.🔥 Network-First with Offline Fallback
For content that should be fresh (like API data or HTML), use network-first: try the network, and fall back to the cache (or an offline page) when the request fails.
// Inside sw.js - network-first with offline fallback
self.addEventListener("fetch", (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Optionally update the cache with the fresh response
const copy = response.clone();
caches.open("runtime").then((cache) =>
cache.put(event.request, copy)
);
return response;
})
.catch(() =>
// Network failed - serve cached version or offline page
caches.match(event.request).then(
(cached) => cached || caches.match("/offline.html")
)
)
);
});Note the response.clone(): a response body can only be read once, so you clone it to both return it and store it. This pattern keeps users online-fresh but offline-capable.
🎯 Your turn: flip the strategy round
Network-first is cache-first with the two branches swapped: try the network, and only reach for the cache when it fails. The stand-in plumbing below is already written for you — only the two lines that decide where the response comes from are missing.
// 🎯 YOUR TURN — fill in the blanks marked with ___
// Everything above onFetch() is the same stand-in harness as before.
const CACHE_NAME = 'app-shell-v1';
const cacheStorage = {
stores: {},
open(name) {
if (!this.stores[name]) this.stores[name] = {};
const store = this.stores[name];
return {
addAll(urls) { urls.forEach(u => { store[u] = 'cached ' + u; }); },
put(url, body) { store[url] = body; }
};
},
match(url) {
for (const name in this.stores) {
if (url in this.stores[name]) return this.stores[name][url];
}
return undefined;
}
};
let networkUp = true;
function fakeFetch(url) {
if (!networkUp) throw new Error('network unreachable');
return 'fresh ' + url;
}
cacheStorage.open(CACHE_NAME).addAll(['/', '/styles.css', '/app.js', '/offline.html']);
function onFetch(url) {
try {
// 1) Network FIRST — ask the server before you look in the cache
const fresh = ___; // 👉 fakeFetch(url)
cacheStorage.open('runtime').put(url, fresh); // keep a copy for later
return 'NETWORK | ' + fresh;
} catch (err) {
// 2) The network failed. Now the cache is the fallback, not the default
const cached = ___; // 👉 cacheStorage.match(url)
return cached
? 'CACHE | ' + cached
: 'OFFLINE | ' + cacheStorage.match('/offline.html');
}
}
console.log('--- online ---');
for (const url of ['/', '/api/news', '/api/news']) {
console.log(url + ' -> ' + onFetch(url));
}
networkUp = false;
console.log('--- offline (aeroplane mode) ---');
for (const url of ['/', '/api/news', '/help/never-visited.html']) {
console.log(url + ' -> ' + onFetch(url));
}
// ✅ Expected output once both blanks are filled:
// --- online ---
// / -> NETWORK | fresh /
// /api/news -> NETWORK | fresh /api/news
// /api/news -> NETWORK | fresh /api/news
// --- offline (aeroplane mode) ---
// / -> CACHE | cached /
// /api/news -> CACHE | fresh /api/news
// /help/never-visited.html -> OFFLINE | cached /offline.html
//
// Compare with the cache-first run: online, every line now says NETWORK,
// so the news is never stale — and the cost is a network round trip even
// for '/', which never changes. That trade-off is why real apps use
// cache-first for assets and network-first for data.🔥 The Web App Manifest and Installability
A PWA becomes installable when you provide a web app manifest — a JSON file describing the app — and link it from your HTML. Combined with a service worker over HTTPS, browsers offer an "Install" prompt.
// manifest.json (linked via <link rel="manifest" href="/manifest.json">)
const manifest = {
name: "My Progressive Web App",
short_name: "MyPWA",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#0d9488",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" }
]
};
console.log("Installable checklist:");
console.log("- Valid manifest:", manifest.name, manifest.start_url);
console.log("- display:", manifest.display);
console.log("- Plus: a registered service worker served over HTTPS");
// ✅ Expected output (this one really does run in the editor):
// Installable checklist:
// - Valid manifest: My Progressive Web App /
// - display: standalone
// - Plus: a registered service worker served over HTTPSKey fields: name/short_name, a start_url, a display mode like standalone, and appropriately sized icons (at least 192px and 512px). Installed PWAs launch in their own window without browser chrome.
🔥 Scope and the HTTPS Requirement
A worker registered at /app/sw.js controls only /app/ and below. You can narrow scope with the scope option, but you cannot widen it beyond the script's directory. And because the worker can rewrite traffic, the origin must be secure.
// Registering with an explicit, narrower scope
navigator.serviceWorker.register("/sw.js", { scope: "/app/" });
// Security check (the browser enforces this automatically)
const origin = "https://example.com";
const isSecure =
origin.startsWith("https://") || origin.startsWith("http://localhost");
console.log("Secure context required for service workers:", isSecure);
console.log("A /sw.js worker defaults to scope: '/'");
console.log("A /app/sw.js worker is limited to '/app/' and below");
// ✅ Expected output — the three console.log lines below really do print.
// The register() call quietly fails in the background because there is
// no /sw.js to register. (On a page served over plain http:// rather
// than https:// or localhost, navigator.serviceWorker does not exist at
// all and that line throws a TypeError instead of printing anything.)
// Secure context required for service workers: true
// A /sw.js worker defaults to scope: '/'
// A /app/sw.js worker is limited to '/app/' and belowHTTPS prevents man-in-the-middle attackers from hijacking the proxy. localhost is exempt so you can develop without a certificate.
🔥 Service Workers vs Web Workers
Both run off the main thread and cannot touch the DOM, but they solve different problems. A Web Worker offloads CPU-heavy work for a single page. A service worker is a persistent, event-driven network proxy shared across all pages in its scope.
| Aspect | Web Worker | Service Worker |
|---|---|---|
| Main purpose | Offload CPU-heavy tasks | Proxy network, cache, offline |
| Lifetime | Tied to its page | Persists across page loads |
| Intercept requests | No | Yes (fetch event) |
| Scope | One page | All pages in its URL scope |
🔥 Common Service Worker Mistakes to Avoid
❌ Anti-Patterns
- Serving over plain HTTP — registration fails outside a secure context (HTTPS or localhost)
- Forgetting event.waitUntil() — the browser may terminate the worker before caching finishes
- Never bumping the cache version — users get stuck on stale assets forever
- Reading a response body twice — clone it with response.clone() before caching
- Expecting DOM access — service workers have no window or document
🎯 Mini-Challenge: Stale-While-Revalidate
Cache-first is fast but stale. Network-first is fresh but slow. Stale-while-revalidate refuses to choose: it hands back the cached copy immediately, and updates the cache in the background so the next request is fresh. It is what most news feeds, avatars and product listings use.
Write onFetch yourself this time — only the outline is given. The fake fetch now stamps a version number on every response, so you can see the cache running exactly one step behind.
// Same harness, but fakeFetch stamps a version so you can spot staleness
const CACHE_NAME = 'app-shell-v1';
const cacheStorage = {
stores: {},
open(name) {
if (!this.stores[name]) this.stores[name] = {};
const store = this.stores[name];
return {
addAll(urls) { urls.forEach(u => { store[u] = 'cached ' + u; }); },
put(url, body) { store[url] = body; }
};
},
match(url) {
for (const name in this.stores) {
if (url in this.stores[name]) return this.stores[name][url];
}
return undefined;
}
};
let networkUp = true;
let version = 0;
function fakeFetch(url) {
if (!networkUp) throw new Error('network unreachable');
version++;
return url + ' v' + version; // every fetch returns a NEWER version
}
cacheStorage.open(CACHE_NAME).addAll(['/', '/offline.html']);
// 🎯 MINI-CHALLENGE: write the stale-while-revalidate strategy
// 1. Look in the cache first and store the result in 'cached'.
// 2. Then try to fetch a fresh copy and put() it in the 'runtime' cache.
// Wrap that in try/catch — being offline must NOT break the response.
// Set fresh to undefined in the catch.
// 3. If 'cached' has something, return 'CACHE | ' + cached (the stale
// copy, served instantly — the revalidate already happened above).
// 4. Otherwise, if 'fresh' has something, return 'NETWORK | ' + fresh.
// 5. Otherwise return 'OFFLINE | ' + cacheStorage.match('/offline.html').
function onFetch(url) {
// your code here
}
console.log('--- online ---');
for (const url of ['/api/news', '/api/news', '/api/news']) {
console.log(url + ' -> ' + onFetch(url));
}
networkUp = false;
console.log('--- offline ---');
for (const url of ['/api/news', '/help/never-visited.html']) {
console.log(url + ' -> ' + onFetch(url));
}
// ✅ Expected output when your version is right:
// --- online ---
// /api/news -> NETWORK | /api/news v1
// /api/news -> CACHE | /api/news v1
// /api/news -> CACHE | /api/news v2
// --- offline ---
// /api/news -> CACHE | /api/news v3
// /help/never-visited.html -> OFFLINE | cached /offline.html
//
// Read the version numbers: the user always gets an answer instantly, and
// the cache is always exactly one revalidation ahead of what they saw.
// If every line says NETWORK you returned 'fresh' before checking 'cached'.
// ❌ TypeError: Cannot read properties of undefined means onFetch returned
// nothing — every branch needs its own return.🎯 Key Takeaways
- A service worker is a background, event-driven network proxy with no DOM access
- Register it with navigator.serviceWorker.register() over HTTPS
- The lifecycle is install → activate → fetch
- Pre-cache on install; clean old caches on activate
- Use the Cache API and caches.match() for cache-first or network-first strategies
- A web app manifest plus a service worker makes a PWA installable
- Scope is bounded by the script's directory; HTTPS is mandatory
- Service workers persist across pages; Web Workers handle per-page CPU work
Practice quiz
What is a service worker?
- A script the browser runs in the background, separate from the page, acting as a network proxy
- A type of database
- A server-side rendering engine
- A CSS preprocessor
Answer: A script the browser runs in the background, separate from the page, acting as a network proxy. A service worker is a background script that runs separately from the web page and can intercept network requests, acting as a programmable proxy.
Which API registers a service worker?
- document.registerSW()
- serviceWorker.start()
- navigator.serviceWorker.register()
- window.installWorker()
Answer: navigator.serviceWorker.register(). You register a service worker by calling navigator.serviceWorker.register('/sw.js'), usually after the page loads.
What is the correct order of the core service worker lifecycle events?
- activate, install, fetch
- install, activate, fetch
- fetch, activate, install
- fetch, install, activate
Answer: install, activate, fetch. A service worker first installs (good for pre-caching), then activates (good for cleaning old caches), then handles fetch events.
During which event do you typically pre-cache the app shell?
- The fetch event
- The message event
- The activate event
- The install event
Answer: The install event. The install event is the ideal place to open a cache and pre-cache the core app shell with event.waitUntil().
Which method looks for a matching cached response?
- caches.match()
- storage.lookup()
- cache.find()
- fetch.cached()
Answer: caches.match(). caches.match(request) searches the Cache Storage for a response that matches the request and returns it if found.
What does a 'cache-first' strategy do?
- It only ever uses the network
- It serves from cache if available, falling back to the network
- It deletes the cache on every request
- It always ignores the cache
Answer: It serves from cache if available, falling back to the network. Cache-first returns a cached response immediately when present and only goes to the network on a cache miss, enabling fast offline-capable loads.
What makes a Progressive Web App installable?
- A large image file
- A single HTML file with no JavaScript
- Disabling the cache
- A valid web app manifest plus a registered service worker served over HTTPS
Answer: A valid web app manifest plus a registered service worker served over HTTPS. Installability requires a web app manifest (name, icons, start_url, display), a service worker, and a secure (HTTPS) origin.
What security requirement do service workers have?
- They only work in incognito mode
- They must be inline in the HTML
- They must be served over HTTPS (localhost is exempt for development)
- They require a paid certificate from the browser vendor
Answer: They must be served over HTTPS (localhost is exempt for development). Because they can intercept network traffic, service workers require a secure context (HTTPS), with localhost allowed for development.
What does a service worker's scope control?
- The size of the cache only
- Which URLs/pages the service worker can control and intercept
- The number of tabs allowed
- The colors of the page
Answer: Which URLs/pages the service worker can control and intercept. Scope defines the set of pages a service worker controls; by default it is the directory of the script and cannot be broader than that path.
How does a service worker differ from a regular Web Worker?
- A service worker is event-driven, persists across page loads, and can intercept network requests, while a Web Worker runs tied to a page for CPU work
- They are identical
- A Web Worker can intercept network requests but a service worker cannot
- A service worker can directly manipulate the DOM
Answer: A service worker is event-driven, persists across page loads, and can intercept network requests, while a Web Worker runs tied to a page for CPU work. A Web Worker offloads CPU work for a single page; a service worker is a persistent, event-driven network proxy shared across pages. Neither can touch the DOM directly.
Continue this course
- Previous: Web Components & Custom Elements
- Next: WebAssembly & JS Interop — Run near-native compiled code and call between WebAssembly and JavaScript
- Quick reference: JavaScript cheat sheet