Skip to main content

⚙️ Service Workers Setup

A service worker is the beating heart of a PWA — a script that runs on its own thread, intercepts every network request your app makes, and decides whether to answer from a cache or the network. In this lesson you'll register one, walk its lifecycle step by step, and write the caching strategies that make an app load instantly and survive offline.

Week 14 · Thursday: Progressive Web Apps · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Register a service worker with feature detection and describe its scope
  • Explain the install → activate → fetch lifecycle and what happens in each phase
  • Precache the app shell during install using the Cache Storage API
  • Use versioned cache names and clean up old caches on activate
  • Intercept requests in fetch and respond with event.respondWith()
  • Implement cache-first, network-first, and stale-while-revalidate strategies, and know when to use each

Estimated Time: 70 minutes

Project: Write a service worker that precaches an app shell and routes requests through the right caching strategy.

In This Lesson

What a Service Worker Is

A service worker is a JavaScript file the browser runs in the background, completely separate from any web page. It is a specialized kind of web worker with three defining traits you must keep in mind:

  • Separate thread. It runs off the main thread, so its work never blocks your UI. It also keeps running after the user navigates away, and the browser may stop and restart it to save memory.
  • No DOM access. It cannot touch document or window. It talks to pages through postMessage, not by reading the DOM. Its global object is self.
  • Event-driven & promise-based. It wakes up to handle events — install, activate, fetch, push, sync — and nearly every API it uses returns a Promise.

📖 The receptionist analogy

Picture a receptionist stationed at the door between your app and the internet. Every time the app asks for something — a stylesheet, an image, an API response — the request passes the receptionist first. They can hand back a copy they already keep in a filing cabinet (the cache), step out to fetch a fresh one from the network, file a new copy for next time, or offer a substitute when the network is down. That receptionist is the service worker, and its filing cabinet is Cache Storage.

Remember the rules from the last lesson: a service worker only runs over HTTPS (or localhost), and it can only control pages within its scope.

Registering One

Registration happens in your page code, not in the service worker itself. Always feature-detect first, and register after the page loads so the service worker never competes with the initial render for bandwidth.

// In your main app script (e.g. /js/app.js)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/service-worker.js')
      .then((registration) => {
        console.log('SW registered. Scope:', registration.scope);
      })
      .catch((error) => {
        console.error('SW registration failed:', error);
      });
  });
}

Scope: where the file lives matters

A service worker can only intercept requests for pages at or below its own location. A worker at /service-worker.js controls the whole site; one at /app/service-worker.js controls only /app/ and below. This is why the file almost always lives at the site root.

// You can narrow the scope, but never widen it beyond the file's folder:
navigator.serviceWorker.register('/service-worker.js', {
  scope: '/',        // control the entire origin
  type: 'module'     // opt in to ES modules inside the worker (import/export)
});

⚠️ You cannot escape your folder

A worker served from /app/ cannot claim a scope of / — the browser rejects it. Serve the file from the highest directory it needs to control. (Advanced hosts can override this with a Service-Worker-Allowed response header, but the default rule is: scope follows the file's location.)

The Lifecycle

Once registered, a service worker moves through a well-defined lifecycle. Understanding it is the single most important thing in this lesson, because almost every confusing service-worker bug is really a lifecycle misunderstanding.

graph TD A["Register"] --> B["Installing
(install event)"] B --> C["Installed / Waiting"] C --> D["Activating
(activate event)"] D --> E["Activated"] E --> F["Idle"] F --> G["Handling fetch / push / sync"] G --> F F --> H["Terminated to save memory"] H -.->|event wakes it| F
  1. Installing. Fires once, right after registration. This is where you precache the app shell.
  2. Installed / Waiting. If an older worker still controls open pages, the new one waits. It will not take over until every tab using the old version is closed — unless you call skipWaiting().
  3. Activating. Fires when the worker takes control. This is where you delete outdated caches.
  4. Activated & idle. The worker now handles fetch and other events. When there's nothing to do, the browser may terminate it and restart it on the next event — which is why you never store important state in a plain variable.

💡 Why the "waiting" step exists

The wait is a safety feature: it prevents a half-updated app where one open tab runs new code and another runs old code. skipWaiting() (in install) plus clients.claim() (in activate) bypasses the wait so the new worker takes over immediately — convenient in development, but use it deliberately in production.

Install: Precache the Shell

The app shell is the minimal HTML, CSS, and JavaScript that renders your app's user interface — the frame the content later drops into. Caching it during install means every future visit paints instantly, online or off.

Use event.waitUntil() to tell the browser "don't consider me installed until this Promise resolves." Inside it, open a versioned cache and add every shell asset with cache.addAll().

// service-worker.js
const CACHE_VERSION = 'v1';
const CACHE_NAME = `app-shell-${CACHE_VERSION}`;

// The app shell: the bare minimum the UI needs to render.
const APP_SHELL = [
  '/',
  '/index.html',
  '/styles/main.css',
  '/js/app.js',
  '/offline.html',
  '/icons/icon-192.png'
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('Precaching app shell');
      return cache.addAll(APP_SHELL);
    })
  );
});

⚠️ addAll is all-or-nothing

cache.addAll() rejects if any single URL fails to fetch, and the whole install fails. Keep the precache list short and correct — one typo'd path (a 404) will silently prevent your service worker from ever installing.

Why version the cache name?

Baking a version into the cache name (app-shell-v1) means that when you ship new code and bump to v2, the new worker fills a brand-new cache while the old one keeps serving open pages. Nothing breaks mid-update, and cleanup becomes trivial — which is exactly what activate handles next.

Activate: Clean Up Caches

Caches never expire on their own — you have to delete them. The activate event is the safe moment to do it, because by now the new worker is taking control and the old caches are no longer needed. Walk every cache name and delete the ones that aren't the current version.

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          // keep the current cache, delete every older app-shell cache
          .filter((name) => name.startsWith('app-shell-') && name !== CACHE_NAME)
          .map((name) => {
            console.log('Deleting old cache:', name);
            return caches.delete(name);
          })
      );
    })
  );
});

💡 skipWaiting + clients.claim

By default a freshly activated worker does not control pages that were already open. Pair self.skipWaiting() in install with self.clients.claim() in activate to have the new worker take charge of existing tabs right away instead of waiting for the next navigation.

self.addEventListener('activate', (event) => {
  event.waitUntil(
    cleanUpOldCaches().then(() => self.clients.claim())
  );
});

Fetch: Intercept Requests

The fetch event is where the magic happens. It fires for every network request the page makes within scope — pages, stylesheets, scripts, images, API calls. Call event.respondWith() with a Promise that resolves to a Response, and you have taken full control of that request.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      // Serve from cache if we have it, otherwise hit the network.
      return cachedResponse || fetch(event.request);
    })
  );
});

That tiny handler is a complete cache-first strategy. But real fetch handlers usually cache the network response for next time too — and that reveals a subtle rule.

Responses can only be read once

A Response body is a stream you can consume a single time. If you want to both return it to the page and store it in the cache, you must clone() it first.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;

      return fetch(event.request).then((networkResponse) => {
        // Clone BEFORE the body is read, so we have two usable copies.
        const copy = networkResponse.clone();
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
        return networkResponse;
      });
    })
  );
});

⚠️ Forgetting to clone

If you read a response twice without cloning — say, cache.put(req, res) and then return res — the second read throws "body already used." Clone first, every time you need the response in two places.

Caching Strategies

A caching strategy is simply your policy for a given request: when do you trust the cache, and when do you trust the network? Different assets deserve different answers. Here are the three you'll reach for most.

graph TD A["Incoming request"] --> B{"What kind of asset?"} B -->|"App shell, fonts, images"| C["Cache First"] B -->|"HTML pages, fresh API data"| D["Network First"] B -->|"Avatars, articles, feeds"| E["Stale While Revalidate"]

Cache-first

Check the cache; only touch the network if it's a miss. Fastest possible response, ideal for static assets that rarely change — CSS, JS bundles, fonts, logos. The risk: users can get a stale asset until the cache is updated, so version your files or cache names.

function cacheFirst(request) {
  return caches.match(request).then((cached) => {
    if (cached) return cached;
    return fetch(request).then((response) => {
      const copy = response.clone();
      caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
      return response;
    });
  });
}

Network-first

Try the network; fall back to the cache if it fails. Best for content that should be as fresh as possible but is still worth showing stale when offline — HTML documents, news feeds, dashboards.

function networkFirst(request) {
  return fetch(request)
    .then((response) => {
      const copy = response.clone();
      caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
      return response;
    })
    .catch(() => caches.match(request)); // offline: serve last known copy
}

Stale-while-revalidate

Answer immediately from the cache, and simultaneously fetch a fresh copy in the background to update the cache for next time. The best of both worlds — instant response plus eventual freshness — for things where a slightly old version is fine right now: avatars, article bodies, non-critical feeds.

function staleWhileRevalidate(request) {
  return caches.open(CACHE_NAME).then((cache) => {
    return cache.match(request).then((cached) => {
      const networkFetch = fetch(request).then((response) => {
        cache.put(request, response.clone()); // refresh for next time
        return response;
      });
      // Return the cached copy now if we have one; otherwise wait for network.
      return cached || networkFetch;
    });
  });
}

Routing requests to strategies

In a real service worker you inspect each request and pick a strategy:

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // HTML navigations: network-first, fall back to offline page.
  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request).catch(() => caches.match('/offline.html')));
    return;
  }
  // Static assets: cache-first.
  if (['style', 'script', 'font'].includes(request.destination)) {
    event.respondWith(cacheFirst(request));
    return;
  }
  // Images and feeds: stale-while-revalidate.
  event.respondWith(staleWhileRevalidate(request));
});
StrategyResponse speedFreshnessUse for
Cache-first⚡ InstantCan be staleCSS, JS, fonts, logos
Network-firstDepends on networkFreshestHTML pages, live data
Stale-while-revalidate⚡ InstantFresh next timeAvatars, articles, feeds

✅ Workbox does this for you

Google's Workbox library packages all of these strategies (and precaching, and cache cleanup) behind a clean API, cutting the boilerplate above to a few lines. Learn the mechanics by hand first — as you have here — then reach for Workbox in production so you're not reinventing cache management on every project.

Best Practices & Pitfalls

✅ Do

  • Feature-detect with if ('serviceWorker' in navigator) before registering
  • Give caches versioned names and delete old ones in activate
  • Wrap install/activate work in event.waitUntil() so the browser waits for it
  • Clone a response before caching and returning it
  • Match the strategy to the asset: cache-first for static, network-first for fresh, stale-while-revalidate for the in-between
  • Provide an /offline.html fallback for failed navigations

❌ Don't

  • Don't store important state in module-level variables — the worker can be terminated and restarted at any time
  • Don't try to widen scope beyond the worker's own directory
  • Don't put user-specific or authenticated API responses in a shared cache-first cache
  • Don't cache POST requests — the Cache API only stores GET responses
  • Don't forget that cache.addAll() fails entirely if one URL 404s

💡 Debugging tip

In Chrome DevTools, open Application → Service Workers to see the current worker's state, tick Update on reload and Bypass for network during development, and inspect Cache Storage to see exactly what's cached. The Offline checkbox in the Network tab lets you test offline behavior without unplugging anything.

Practice & Quiz

🏋️ Exercise 1: A minimal offline-capable worker

Goal: Write a service worker that precaches /, /index.html, /styles/main.css, and /offline.html on install, and serves cache-first with an offline fallback for navigations.

💡 Hint

Open a versioned cache in install and addAll the shell. In fetch, try caches.match first; if it misses, fetch; and if the request is a navigation that fails, return caches.match('/offline.html').

✅ Solution
const CACHE = 'shell-v1';
const SHELL = ['/', '/index.html', '/styles/main.css', '/offline.html'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n)))
    )
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request).catch(() => {
        if (event.request.mode === 'navigate') return caches.match('/offline.html');
      });
    })
  );
});

🏋️ Exercise 2: Pick the strategy

Goal: For each asset, name the strategy you'd choose and why: (a) the site's main.css, (b) a stock-price API endpoint, (c) user profile avatars.

✅ Solution
  • (a) main.css → cache-first. A static, versioned asset; instant load matters and staleness is controlled by the file name.
  • (b) stock-price API → network-first (or network-only). Freshness is the whole point; fall back to cache only so an offline view shows the last known price.
  • (c) avatars → stale-while-revalidate. Show the cached image instantly, quietly refresh it for next time; a slightly old avatar is harmless.

🎯 Quick Quiz

Question 1: In which lifecycle event should you delete outdated caches?

Question 2: Why must you call response.clone() before caching a fetched response?

Question 3: Which strategy returns the cached copy immediately while fetching a fresh one in the background?

Summary

🎉 Key Takeaways

  • A service worker runs on a separate thread, has no DOM access, is event-driven, and only runs over HTTPS or localhost
  • You register it from page code with feature detection; its scope follows the file's location
  • The lifecycle is install → activate → fetch: precache in install, clean up caches in activate, intercept requests in fetch
  • Use versioned cache names and delete old ones on activate
  • Always clone a response you want to both cache and return
  • Match strategy to asset: cache-first for static, network-first for fresh, stale-while-revalidate for the middle ground; Workbox packages all three

📚 Additional Resources

🚀 What's Next?

Your service worker can now cache assets and survive a lost connection. But a truly resilient app does more than serve stale HTML — it stores data offline, queues user actions, and syncs them back when the network returns. The next lesson dives into that: Offline Functionality, covering offline fallback pages, IndexedDB for structured data, and the Background Sync API.

🎉 Great work!

You've built the engine of a PWA by hand. Everything offline-capable stands on the lifecycle and strategies you just wrote.