JavaScript — Service Workers
Service workers are JavaScript files that run in the background, separate from the web page, acting as a programmable network proxy. They enable features that were previously impossible for web applications — offline support, push notifications, background sync, and advanced caching strategies.
Unlike traditional web scripts, service workers run on a separate thread and have no DOM access. They intercept network requests, manage cache storage, and can wake up in response to events even when the page is not open. They are the foundation of Progressive Web Apps (PWAs).
This page covers the service worker lifecycle, caching strategies, push notifications, and best practices for production deployments.
| 1 | // Register a service worker |
| 2 | if ("serviceWorker" in navigator) { |
| 3 | navigator.serviceWorker.register("/sw.js") |
| 4 | .then((registration) => { |
| 5 | console.log("SW registered:", registration.scope); |
| 6 | }) |
| 7 | .catch((error) => { |
| 8 | console.error("SW registration failed:", error); |
| 9 | }); |
| 10 | } |
| 11 | |
| 12 | // sw.js — basic service worker skeleton |
| 13 | self.addEventListener("install", (event) => { |
| 14 | console.log("Service worker installing"); |
| 15 | self.skipWaiting(); |
| 16 | }); |
| 17 | |
| 18 | self.addEventListener("activate", (event) => { |
| 19 | console.log("Service worker activating"); |
| 20 | event.waitUntil(clients.claim()); |
| 21 | }); |
| 22 | |
| 23 | self.addEventListener("fetch", (event) => { |
| 24 | console.log("Intercepting:", event.request.url); |
| 25 | event.respondWith(fetch(event.request)); |
| 26 | }); |
A service worker goes through three lifecycle stages: Installation, Activation, and Runtime. Understanding this lifecycle is critical for correct cache management and update behavior. The lifecycle ensures that only one version of a service worker controls a given scope at any time.
| Stage | Event | Description |
|---|---|---|
| Install | install | Fired when the browser downloads the new SW. Use for precaching static assets. |
| Waiting | — | New SW waits until all tabs using the old SW are closed. self.skipWaiting() bypasses this. |
| Activate | activate | Fired when the SW takes control. Use for cleaning old caches and claiming clients. |
| Runtime | fetch, push, sync | Active SW intercepts events. It is terminated when idle and restarted for events. |
| Redundant | — | The SW is replaced by a newer version or installation failed irrecoverably. |
| 1 | // Lifecycle event handlers with self.skipWaiting() and clients.claim() |
| 2 | |
| 3 | const CACHE_NAME = "my-app-v1"; |
| 4 | const PRECACHE_URLS = ["/", "/styles.css", "/app.js"]; |
| 5 | |
| 6 | // INSTALL — precache static assets |
| 7 | self.addEventListener("install", (event) => { |
| 8 | console.log("[SW] Installing..."); |
| 9 | |
| 10 | event.waitUntil( |
| 11 | caches.open(CACHE_NAME).then((cache) => { |
| 12 | console.log("[SW] Precaching assets"); |
| 13 | return cache.addAll(PRECACHE_URLS); |
| 14 | }).then(() => { |
| 15 | // Force the waiting SW to become active immediately |
| 16 | return self.skipWaiting(); |
| 17 | }) |
| 18 | ); |
| 19 | }); |
| 20 | |
| 21 | // ACTIVATE — clean old caches and claim clients |
| 22 | self.addEventListener("activate", (event) => { |
| 23 | console.log("[SW] Activating..."); |
| 24 | |
| 25 | event.waitUntil( |
| 26 | caches.keys().then((cacheNames) => { |
| 27 | return Promise.all( |
| 28 | cacheNames |
| 29 | .filter((name) => name !== CACHE_NAME) |
| 30 | .map((name) => { |
| 31 | console.log("[SW] Deleting old cache:", name); |
| 32 | return caches.delete(name); |
| 33 | }) |
| 34 | ); |
| 35 | }).then(() => { |
| 36 | // Take control of all open tabs immediately |
| 37 | return clients.claim(); |
| 38 | }) |
| 39 | ); |
| 40 | }); |
info
Service workers are registered from a page's JavaScript. The registration scope determines which pages the service worker controls. A service worker placed at the root of the origin (/sw.js) controls all pages; one placed in a subdirectory only controls pages under that path.
| 1 | // Registration with scope control |
| 2 | if ("serviceWorker" in navigator) { |
| 3 | // Register with explicit scope |
| 4 | navigator.serviceWorker.register("/sw.js", { |
| 5 | scope: "/app/" |
| 6 | }).then((reg) => { |
| 7 | console.log("SW registered:", reg.scope); |
| 8 | |
| 9 | // Detect updates |
| 10 | reg.addEventListener("updatefound", () => { |
| 11 | const newWorker = reg.installing; |
| 12 | console.log("New SW found:", newWorker.state); |
| 13 | |
| 14 | newWorker.addEventListener("statechange", () => { |
| 15 | if (newWorker.state === "installed") { |
| 16 | if (navigator.serviceWorker.controller) { |
| 17 | // New version available — prompt user to update |
| 18 | console.log("Update available — refresh to activate"); |
| 19 | } |
| 20 | } |
| 21 | }); |
| 22 | }); |
| 23 | }).catch((err) => { |
| 24 | console.error("Registration failed:", err); |
| 25 | }); |
| 26 | |
| 27 | // Re-register if connection lost and restored |
| 28 | navigator.serviceWorker.register("/sw.js"); |
| 29 | |
| 30 | // Check for updates |
| 31 | navigator.serviceWorker.ready.then((reg) => { |
| 32 | reg.update(); // Check for new SW version |
| 33 | }); |
| 34 | } |
| 35 | |
| 36 | // Service worker scope effects |
| 37 | // /sw.js → controls everything under origin |
| 38 | // /app/sw.js → controls only /app/ paths |
| 39 | // scope: "/admin/" → controls only /admin/ paths |
warning
The fetch event fires for every network request made by pages within the service worker's scope. You can intercept the request, serve a cached response, modify the request, or forward it to the network. This is where caching strategies are implemented.
| 1 | // Fetch event interception |
| 2 | self.addEventListener("fetch", (event) => { |
| 3 | // GET requests only |
| 4 | if (event.request.method !== "GET") return; |
| 5 | |
| 6 | // Skip non-http(s) requests |
| 7 | if (!event.request.url.startsWith("http")) return; |
| 8 | |
| 9 | // Skip browser extension requests |
| 10 | if (!event.request.url.startsWith(self.location.origin)) return; |
| 11 | |
| 12 | // Skip API calls (let them go to network) |
| 13 | if (event.request.url.includes("/api/")) { |
| 14 | return; |
| 15 | } |
| 16 | |
| 17 | event.respondWith(handleRequest(event.request)); |
| 18 | }); |
| 19 | |
| 20 | async function handleRequest(request) { |
| 21 | // Try cache first |
| 22 | const cached = await caches.match(request); |
| 23 | if (cached) { |
| 24 | console.log("Serving from cache:", request.url); |
| 25 | return cached; |
| 26 | } |
| 27 | |
| 28 | // Fallback to network |
| 29 | try { |
| 30 | const response = await fetch(request); |
| 31 | // Cache successful responses for next time |
| 32 | if (response.ok) { |
| 33 | const cache = await caches.open("dynamic-v1"); |
| 34 | cache.put(request, response.clone()); |
| 35 | } |
| 36 | return response; |
| 37 | } catch (error) { |
| 38 | // Network failed — serve offline fallback |
| 39 | console.log("Network failed, serving offline page"); |
| 40 | return caches.match("/offline.html"); |
| 41 | } |
| 42 | } |
pro tip
There are several established caching strategies for service workers, each suited to different resource types. The choice depends on whether freshness or availability is more important for your use case.
Cache First (Offline-First)
| 1 | // Cache First — serve from cache, update in background |
| 2 | // Best for: static assets, images, versioned resources |
| 3 | self.addEventListener("fetch", (event) => { |
| 4 | event.respondWith(cacheFirst(event.request)); |
| 5 | }); |
| 6 | |
| 7 | async function cacheFirst(request) { |
| 8 | const cached = await caches.match(request); |
| 9 | if (cached) return cached; |
| 10 | |
| 11 | try { |
| 12 | const response = await fetch(request); |
| 13 | if (response.ok) { |
| 14 | const cache = await caches.open("static-v1"); |
| 15 | cache.put(request, response.clone()); |
| 16 | } |
| 17 | return response; |
| 18 | } catch (error) { |
| 19 | return caches.match("/offline.html"); |
| 20 | } |
| 21 | } |
Network First (Stale-While-Revalidate)
| 1 | // Network First — try network, fall back to cache |
| 2 | // Best for: API responses, HTML pages, frequently updated content |
| 3 | self.addEventListener("fetch", (event) => { |
| 4 | event.respondWith(networkFirst(event.request)); |
| 5 | }); |
| 6 | |
| 7 | async function networkFirst(request) { |
| 8 | try { |
| 9 | const response = await fetch(request); |
| 10 | if (response.ok) { |
| 11 | const cache = await caches.open("dynamic-v1"); |
| 12 | cache.put(request, response.clone()); |
| 13 | } |
| 14 | return response; |
| 15 | } catch (error) { |
| 16 | const cached = await caches.match(request); |
| 17 | if (cached) { |
| 18 | console.log("Network failed, serving cached:", request.url); |
| 19 | return cached; |
| 20 | } |
| 21 | return caches.match("/offline.html"); |
| 22 | } |
| 23 | } |
Cache Only / Network Only
| 1 | // Cache Only — for immutable, precached resources |
| 2 | async function cacheOnly(request) { |
| 3 | const cached = await caches.match(request); |
| 4 | return cached || new Response("Not found", { status: 404 }); |
| 5 | } |
| 6 | |
| 7 | // Network Only — for dynamic API calls that must be fresh |
| 8 | async function networkOnly(request) { |
| 9 | return fetch(request); |
| 10 | } |
| 11 | |
| 12 | // Conditional strategy — mix based on request type |
| 13 | self.addEventListener("fetch", (event) => { |
| 14 | const url = new URL(event.request.url); |
| 15 | |
| 16 | if (url.pathname.startsWith("/api/")) { |
| 17 | // API calls — network first |
| 18 | event.respondWith(networkFirst(event.request)); |
| 19 | } else if (url.pathname.match(/\.(png|jpg|css|js)$/)) { |
| 20 | // Static assets — cache first |
| 21 | event.respondWith(cacheFirst(event.request)); |
| 22 | } else { |
| 23 | // HTML — network first |
| 24 | event.respondWith(networkFirst(event.request)); |
| 25 | } |
| 26 | }); |
best practice
The Background Sync API allows service workers to defer actions until the user has a stable network connection. This is ideal for queuing form submissions, analytics events, or any data that should be sent when connectivity is restored, even if the user closes the page.
| 1 | // Page: register a sync event |
| 2 | async function sendWhenOnline(data) { |
| 3 | // Try immediate send |
| 4 | try { |
| 5 | const response = await fetch("/api/submit", { |
| 6 | method: "POST", |
| 7 | body: JSON.stringify(data), |
| 8 | headers: { "Content-Type": "application/json" } |
| 9 | }); |
| 10 | if (response.ok) return; |
| 11 | } catch (e) { |
| 12 | // Network failed — queue for background sync |
| 13 | } |
| 14 | |
| 15 | // Save to IndexedDB for later |
| 16 | const db = await openDB("sync-queue", 1, { |
| 17 | upgrade(db) { db.createObjectStore("pending"); } |
| 18 | }); |
| 19 | await db.put("pending", data, Date.now().toString()); |
| 20 | |
| 21 | // Register a sync event |
| 22 | const reg = await navigator.serviceWorker.ready; |
| 23 | await reg.sync.register("submit-pending"); |
| 24 | } |
| 25 | |
| 26 | // Service Worker: handle sync event |
| 27 | self.addEventListener("sync", (event) => { |
| 28 | if (event.tag === "submit-pending") { |
| 29 | event.waitUntil(processPendingSubmissions()); |
| 30 | } |
| 31 | }); |
| 32 | |
| 33 | async function processPendingSubmissions() { |
| 34 | const db = await openDB("sync-queue", 1); |
| 35 | const pending = await db.getAll("pending"); |
| 36 | |
| 37 | for (const data of pending) { |
| 38 | try { |
| 39 | const response = await fetch("/api/submit", { |
| 40 | method: "POST", |
| 41 | body: JSON.stringify(data), |
| 42 | headers: { "Content-Type": "application/json" } |
| 43 | }); |
| 44 | if (response.ok) { |
| 45 | await db.clear("pending"); |
| 46 | } |
| 47 | } catch (e) { |
| 48 | // Will retry on next sync event |
| 49 | console.log("Sync failed, will retry later"); |
| 50 | } |
| 51 | } |
| 52 | } |
info
Service workers can receive push messages from a server even when the page is closed, using the Push API and the PushSubscription mechanism. This requires a service worker, a push service, and a set of cryptographic keys for secure communication.
| 1 | // Page: subscribe to push notifications |
| 2 | async function subscribeToPush() { |
| 3 | const registration = await navigator.serviceWorker.ready; |
| 4 | |
| 5 | // Get VAPID public key from server |
| 6 | const response = await fetch("/api/push/vapid-key"); |
| 7 | const vapidKey = await response.text(); |
| 8 | |
| 9 | const subscription = await registration.pushManager.subscribe({ |
| 10 | userVisibleOnly: true, |
| 11 | applicationServerKey: urlBase64ToUint8Array(vapidKey) |
| 12 | }); |
| 13 | |
| 14 | // Send subscription to server |
| 15 | await fetch("/api/push/subscribe", { |
| 16 | method: "POST", |
| 17 | body: JSON.stringify(subscription) |
| 18 | }); |
| 19 | } |
| 20 | |
| 21 | // Service Worker: handle push events |
| 22 | self.addEventListener("push", (event) => { |
| 23 | let data = { title: "New Notification", body: "", icon: "/icon.png" }; |
| 24 | |
| 25 | if (event.data) { |
| 26 | try { |
| 27 | data = event.data.json(); |
| 28 | } catch { |
| 29 | data.body = event.data.text(); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | event.waitUntil( |
| 34 | self.registration.showNotification(data.title, { |
| 35 | body: data.body, |
| 36 | icon: data.icon, |
| 37 | badge: "/badge.png", |
| 38 | tag: data.tag || "default", |
| 39 | data: { url: data.url || "/" }, |
| 40 | actions: [ |
| 41 | { action: "open", title: "Open" }, |
| 42 | { action: "dismiss", title: "Dismiss" } |
| 43 | ] |
| 44 | }) |
| 45 | ); |
| 46 | }); |
| 47 | |
| 48 | // Handle notification click |
| 49 | self.addEventListener("notificationclick", (event) => { |
| 50 | event.notification.close(); |
| 51 | |
| 52 | if (event.action === "dismiss") return; |
| 53 | |
| 54 | event.waitUntil( |
| 55 | clients.openWindow(event.notification.data.url) |
| 56 | ); |
| 57 | }); |
warning
pro tip