Service workers are the engine behind Progressive Web Apps. They are JavaScript files that run in a separate thread from your web page, acting as a programmable network proxy between your app and the server.
How Service Workers Differ from Regular JavaScript
| Property | Web Page JS | Service Worker |
|---|---|---|
| Thread | Main thread | Separate background thread |
| DOM access | Yes | No |
| Lifecycle | Tied to page | Independent of page |
| Network access | Direct | Intercepts all requests |
| HTTPS required | No | Yes (except localhost) |
| Persistent storage | localStorage, IndexedDB | Cache API, IndexedDB |
Registration
Register the service worker from your main JavaScript file:
// main.js
async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
console.log("Service workers not supported");
return;
}
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
console.log("SW registered with scope:", registration.scope);
// Check for updates
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
console.log("New service worker installing...");
newWorker.addEventListener("statechange", () => {
console.log("SW state:", newWorker.state);
});
});
} catch (error) {
console.error("SW registration failed:", error);
}
}
registerServiceWorker();Scope
The scope determines which pages the service worker controls. By default, it is the directory where sw.js lives.
// Controls only /app/ and below
navigator.serviceWorker.register("/app/sw.js");
// Controls everything from root
navigator.serviceWorker.register("/sw.js", { scope: "/" });The Install Event
The install event fires when the browser detects a new or updated service worker. This is where you pre-cache your app shell.
// sw.js
const CACHE_NAME = "app-shell-v1";
const APP_SHELL = [
"/",
"/index.html",
"/css/styles.css",
"/js/app.js",
"/js/router.js",
"/images/logo.svg",
"/offline.html",
];
self.addEventListener("install", (event) => {
console.log("[SW] Installing...");
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log("[SW] Caching app shell");
return cache.addAll(APP_SHELL);
})
.then(() => {
// Activate immediately without waiting for old SW to stop
return self.skipWaiting();
})
);
});event.waitUntil() tells the browser to keep the service worker alive until the promise resolves. If the promise rejects, the installation fails.
The Activate Event
The activate event fires after installation, when the service worker takes control. Use this to clean up old caches.
self.addEventListener("activate", (event) => {
console.log("[SW] Activating...");
const allowedCaches = [CACHE_NAME, "dynamic-v1"];
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => !allowedCaches.includes(name))
.map((name) => {
console.log("[SW] Deleting old cache:", name);
return caches.delete(name);
})
);
})
.then(() => {
// Take control of all open pages immediately
return self.clients.claim();
})
);
});skipWaiting and clients.claim
By default, a new service worker waits until all tabs using the old one are closed. These methods bypass that:
self.skipWaiting()— activates the new worker immediatelyself.clients.claim()— takes control of all open pages without a reload
The Fetch Event
This is where the magic happens. The service worker intercepts every network request from controlled pages.
self.addEventListener("fetch", (event) => {
const { request } = event;
// Only handle GET requests
if (request.method !== "GET") return;
event.respondWith(handleFetch(request));
});
async function handleFetch(request) {
// Try cache first
const cached = await caches.match(request);
if (cached) {
return cached;
}
// Fall back to network
try {
const response = await fetch(request);
// Cache successful responses
if (response.ok) {
const cache = await caches.open("dynamic-v1");
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed — show offline page for navigation requests
if (request.mode === "navigate") {
return caches.match("/offline.html");
}
throw error;
}
}Why clone the response?
A response body can only be read once. If you want to cache the response and also return it to the page, you must clone it:
const response = await fetch(request);
cache.put(request, response.clone()); // clone for cache
return response; // original for page
The Cache API
The Cache API stores request-response pairs.
// Open or create a cache
const cache = await caches.open("my-cache");
// Add a URL (fetches and stores)
await cache.add("/api/data");
// Add multiple URLs
await cache.addAll(["/page1.html", "/page2.html"]);
// Store a custom response
await cache.put("/api/data", new Response(JSON.stringify({ cached: true })));
// Retrieve from cache
const response = await cache.match("/api/data");
// Delete an entry
await cache.delete("/api/data");
// List all cached requests
const keys = await cache.keys();
// Delete an entire cache
await caches.delete("my-cache");Updating Service Workers
When you change your sw.js file (even one byte), the browser treats it as a new version.
Update Flow
- Browser fetches
sw.jsand detects changes - New service worker installs alongside the old one
- New worker enters "waiting" state
- When all tabs using the old worker close, the new one activates
Versioned Caches
Use version numbers in cache names so the activate event can clean up old caches:
const CACHE_VERSION = 3;
const CACHE_NAME = `app-shell-v${CACHE_VERSION}`;
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name.startsWith("app-shell-") && name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
)
);
});Communicating with the Page
Service workers and pages can exchange messages:
Page to Service Worker
// main.js
navigator.serviceWorker.controller.postMessage({
type: "CLEAR_CACHE",
});
// sw.js
self.addEventListener("message", (event) => {
if (event.data.type === "CLEAR_CACHE") {
caches.keys().then((names) => names.forEach((n) => caches.delete(n)));
}
});Service Worker to Page
// sw.js
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({ type: "CACHE_UPDATED" });
});
});
// main.js
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data.type === "CACHE_UPDATED") {
console.log("Cache was updated, refreshing data...");
}
});Debugging Service Workers
Use Chrome DevTools:
- Open Application tab
- Click Service Workers in the sidebar
- See registered workers, their state, and controls to:
- Update — force check for a new version
- Unregister — remove the service worker
- skipWaiting — activate a waiting worker
Check Cache Storage to see what is cached and Network tab to see which requests the service worker intercepted.
Practical Exercise
Build a service worker with proper versioning and cleanup:
// sw.js
const VERSION = 1;
const STATIC_CACHE = `static-v${VERSION}`;
const DYNAMIC_CACHE = `dynamic-v${VERSION}`;
const STATIC_ASSETS = [
"/",
"/index.html",
"/css/app.css",
"/js/app.js",
"/offline.html",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k !== DYNAMIC_CACHE)
.map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
return caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(event.request, response.clone());
return response;
});
});
}).catch(() => {
if (event.request.mode === "navigate") {
return caches.match("/offline.html");
}
})
);
});Key Takeaways
- Service workers run in a background thread with no DOM access but full control over network requests.
- The lifecycle (install, activate, fetch) gives you precise control over caching and updates.
- Always clone responses before caching — a response body can only be consumed once.
- Use versioned cache names and clean up old caches during activation.
skipWaiting()andclients.claim()let new service workers take over immediately.