|$ curl https://forge-ai.dev/api/markdown?path=docs/js/service-workers
$cat docs/javascript-—-service-workers.md
updated This week·35 min read·published

JavaScript — Service Workers

JavaScriptService WorkersAdvancedAdvanced
Introduction

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.

sw-intro.js
JavaScript
1// Register a service worker
2if ("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
13self.addEventListener("install", (event) => {
14 console.log("Service worker installing");
15 self.skipWaiting();
16});
17
18self.addEventListener("activate", (event) => {
19 console.log("Service worker activating");
20 event.waitUntil(clients.claim());
21});
22
23self.addEventListener("fetch", (event) => {
24 console.log("Intercepting:", event.request.url);
25 event.respondWith(fetch(event.request));
26});
Lifecycle

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.

StageEventDescription
InstallinstallFired when the browser downloads the new SW. Use for precaching static assets.
WaitingNew SW waits until all tabs using the old SW are closed. self.skipWaiting() bypasses this.
ActivateactivateFired when the SW takes control. Use for cleaning old caches and claiming clients.
Runtimefetch, push, syncActive SW intercepts events. It is terminated when idle and restarted for events.
RedundantThe SW is replaced by a newer version or installation failed irrecoverably.
lifecycle.js
JavaScript
1// Lifecycle event handlers with self.skipWaiting() and clients.claim()
2
3const CACHE_NAME = "my-app-v1";
4const PRECACHE_URLS = ["/", "/styles.css", "/app.js"];
5
6// INSTALL — precache static assets
7self.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
22self.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

Always call self.skipWaiting() during install and clients.claim() during activate in development. In production, you may want to delay activation to ensure the user has a consistent experience — but for most apps, immediate activation is the right choice.
Installation & Registration

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.

registration.js
JavaScript
1// Registration with scope control
2if ("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

Service workers only work over HTTPS (or localhost for development). The HTTPS requirement is non-negotiable — a MITM attacker could otherwise inject a malicious service worker. During development, most browsers support service workers on http://localhost or http://127.0.0.1.
Fetch Events

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.

fetch-events.js
JavaScript
1// Fetch event interception
2self.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
20async 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

Always call event.respondWith() synchronously in the fetch handler. The browser expects a response synchronously and will wait for the promise to resolve, but you must call respondWith inside the event handler callback — not after an await. Use this pattern to inspect the request and defer to a handler function.
Caching Strategies

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)

cache-first.js
JavaScript
1// Cache First — serve from cache, update in background
2// Best for: static assets, images, versioned resources
3self.addEventListener("fetch", (event) => {
4 event.respondWith(cacheFirst(event.request));
5});
6
7async 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)

network-first.js
JavaScript
1// Network First — try network, fall back to cache
2// Best for: API responses, HTML pages, frequently updated content
3self.addEventListener("fetch", (event) => {
4 event.respondWith(networkFirst(event.request));
5});
6
7async 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

strategies-mixed.js
JavaScript
1// Cache Only — for immutable, precached resources
2async 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
8async function networkOnly(request) {
9 return fetch(request);
10}
11
12// Conditional strategy — mix based on request type
13self.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});
preview

best practice

Use versioned cache names (e.g., static-v2) and delete old caches during activation. This ensures users always get the latest assets after a deployment without stale cache contamination. Never mix versioned and unversioned URLs in the same cache — a single cache invalidation bug can break your entire offline experience.
Background Sync

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.

background-sync.js
JavaScript
1// Page: register a sync event
2async 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
27self.addEventListener("sync", (event) => {
28 if (event.tag === "submit-pending") {
29 event.waitUntil(processPendingSubmissions());
30 }
31});
32
33async 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

Background sync events have a limited retry window — browsers may not retry indefinitely. Chrome typically retries with exponential backoff for a few hours. For critical data, combine background sync with a periodic check on page load to ensure nothing was missed.
Push Notifications

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.

push-notifications.js
JavaScript
1// Page: subscribe to push notifications
2async 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
22self.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
49self.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

Push notifications require user consent and must be triggered by a user gesture (click or tap). You cannot show notifications without a subscription, and subscriptions must be refreshed periodically. VAPID keys authenticate your server with the push service — never expose the private key in client-side code.
Best Practices
Always use HTTPS — service workers will not work on insecure origins
Version your cache names and clean old caches during the activate event
Use self.skipWaiting() and clients.claim() carefully — immediate update vs controlled rollout
Keep the install event focused on precaching — do not perform I/O or complex logic
Handle fetch errors gracefully — always have a fallback response for offline scenarios
Avoid caching large binary files unless they are truly needed offline
Use the Cache-Control header from HTTP responses to inform caching decisions
Register service workers early in the page load but do not block rendering
Test service workers in incognito mode and after clearing site data to avoid stale state
Monitor service worker registration failures in your analytics — they indicate deployment issues
🔥

pro tip

Use the Application panel in Chrome DevTools to inspect service workers, check their status, simulate offline mode, and manually trigger update/install/unregister cycles. The "Update on reload" checkbox forces the SW to update on every page load — essential during development but never enable it in production.
$Blueprint — Engineering Documentation·Section ID: JS-SERVICEWORKERS·Revision: 1.0