Skip to main content
Progressive Web Apps·Lesson 2 of 5

Service Workers

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

PropertyWeb Page JSService Worker
ThreadMain threadSeparate background thread
DOM accessYesNo
LifecycleTied to pageIndependent of page
Network accessDirectIntercepts all requests
HTTPS requiredNoYes (except localhost)
Persistent storagelocalStorage, IndexedDBCache 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 immediately
  • self.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

  1. Browser fetches sw.js and detects changes
  2. New service worker installs alongside the old one
  3. New worker enters "waiting" state
  4. 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:

  1. Open Application tab
  2. Click Service Workers in the sidebar
  3. 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() and clients.claim() let new service workers take over immediately.