Chapter 85: Progressive Web Apps (PWAs)

Service Worker Lifecycle

Mastering background thread execution, byte-diff registration, the installing-waiting-active state machine, `skipWaiting()`, and `clients.claim()`.

LEARNING OBJECTIVES
  • Understand the Service Worker threading model and how it operates completely independently of the main browser UI thread and DOM.
  • Trace the 6 lifecycle phases: Parsed / Installing, Installed / Waiting, Activating, Active, and Redundant.
  • Master update mechanics, byte-by-byte script change detection, and cache migration during the activate event.
  • Control lifecycle progression using event.waitUntil(), self.skipWaiting(), self.clients.claim(), and the controllerchange event.
🎬 INTERACTIVE VISUAL PIPELINE Core Architecture Simulation
🌐
1. Input
Directives & Tags
⚙️
2. Parse
Tokenizer & AST
🌳
3. Layout
Box Model & Flow
🎨
4. Render
GPU Paint & Composite
PHASE 1: INPUT & DIRECTIVES
Browser receives declarative markup stream, parsing tag tokens and initializing component state.

📖 The Mental Model & Story (Intuitive Foundation)

Imagine an international airport operating a massive security and luggage routing system. The passengers (the user and the DOM UI) move freely through the departure halls. Beneath their feet, an automated underground conveyor network (the Service Worker) intercepts, inspects, and routes every bag (network HTTP requests) coming from the airplanes (the cloud server) or luggage holding bays (the local CacheStorage).

When the airport upgrades the underground machinery to version 2.0, they cannot simply shut down the conveyor belt while passengers are in transit—doing so would lose luggage and cause chaos. Instead, the new version 2.0 system is constructed and tested in the background (Installing). Once built, it sits quietly on standby (Waiting) until every passenger currently in the terminal departs and the airport closes for the night. Only when all existing sessions terminate does version 2.0 switch on (Activating), dismantle the old version 1.0 gear, and take over baggage handling (Active).

If an urgent emergency patch is needed, an engineer can flip an emergency bypass switch (self.skipWaiting()) to force the new system into immediate control without waiting for existing tabs to close.


Technical Deep Dive & Specifications

The Service Worker State Machine

A Service Worker is an event-driven JavaScript worker running on the ServiceWorkerGlobalScope. It has no synchronous access to window or the DOM (document), communicating with pages exclusively via postMessage or network interception.

                  +-----------------------------------+
                  |   navigator.serviceWorker.        |
                  |          register()               |
                  +-----------------------------------+
                                    |
                                    v
+------------------+      +-------------------+
|      PARSED      | ===> |    INSTALLING     | (install event: Precaching assets)
+------------------+      +-------------------+
                                    |
                 +------------------+------------------+
                 | (Install Fails)                     | (Install Succeeds)
                 v                                     v
       +-------------------+                 +-------------------+
       |     REDUNDANT     |                 |  INSTALLED / WAIT | (Waiting for existing
       +-------------------+                 +-------------------+  tabs to close)
                                                       |
                                    +------------------+
                                    | (Old SW dies OR self.skipWaiting())
                                    v
                          +-------------------+
                          |    ACTIVATING     | (activate event: Purging old caches)
                          +-------------------+
                                    |
                                    v
                          +-------------------+
                          |      ACTIVE       | (fetch, sync, push events)
                          +-------------------+
                                    |
                                    | (Replaced by New Worker)
                                    v
                          +-------------------+
                          |     REDUNDANT     | (Old worker destroyed)
                          +-------------------+

Lifecycle Phases Explained

Phase Event Trigger Global Methods Available Primary Objective
Registration navigator.serviceWorker.register() Promise<ServiceWorkerRegistration> Browser downloads SW script from origin and checks scope.
Installing install event event.waitUntil(), self.skipWaiting() Download and precache all critical static App Shell assets into CacheStorage.
Waiting None (Idle State) registration.waiting New SW is ready, but older SW is still controlling one or more active open browser tabs.
Activating activate event event.waitUntil(), self.clients.claim() Iterate over caches.keys() to delete obsolete cache buckets from previous versions.
Active fetch, push, sync event.respondWith(), self.clients Fully in control of network traffic for all clients within scope.
Redundant None (Dead State) None Worker failed installation or was superseded by a newly activated worker.

The Scope Rule & Directory Inheritance

A Service Worker can only control pages within its own directory scope or subdirectories. It can never control parent directories unless explicitly permitted via the Service-Worker-Allowed HTTP response header:

File Location: /js/sw.js
Default Maximum Scope: /js/*
Will NOT control: /index.html or /dashboard/

File Location: /sw.js (At Root)
Default Maximum Scope: /*
Controls: All pages on the entire origin!

skipWaiting() vs clients.claim() Matrix

                          +----------------------------------------------+
                          |             CLIENT BROWSER TABS              |
                          |   [ Tab 1: v1.0 ]          [ Tab 2: v1.0 ]   |
                          +----------------------------------------------+
                                                 ^
                                                 | (Controlled by)
+------------------------+             +-------------------+
|  NEW WORKER (v2.0)     |             |  OLD WORKER (v1.0)|
|  Calls: skipWaiting()  |             |      ACTIVE       |
+------------------------+             +-------------------+
           |                                     |
           +============ Kills & Replaces =======+
           |
           v
+------------------------+
|  NEW WORKER (v2.0)     |
|      ACTIVATED         |
|  Calls: clients.claim()| === Immediate control of Tab 1 & Tab 2 without reload!
+------------------------+
  • self.skipWaiting(): Forces the waiting worker to activate immediately, bypassing the requirement that all active tabs be closed.
  • self.clients.claim(): Tells an activated worker to immediately take control of all uncontrolled or legacy-controlled open tabs without requiring those pages to be refreshed.

💻 Interactive Code Playground

Starter Code: Production Service Worker Harness

1. File: app.js (Main UI Thread)

2. File: sw.js (Service Worker Background Thread)

Line-by-Line Code Breakdown

  • app.js Line 5 (registration.scope): The path prefix over which this Service Worker intercepts network requests.
  • app.js Line 13 (registration.addEventListener('updatefound')): Fires whenever a byte-level difference is detected in sw.js during page load.
  • app.js Line 31 (navigator.serviceWorker.addEventListener('controllerchange')): Fires when the active controlling worker changes; provides a reliable trigger to reload the DOM without race conditions.
  • sw.js Line 11 (event.waitUntil(...)): Extends the lifecycle phase until the passed promise resolves; prevents the browser from terminating the worker prematurely.
  • sw.js Line 29 (self.clients.claim()): Instructs the newly activated worker to immediately begin controlling open tabs without waiting for subsequent navigations.
  • sw.js Line 37 (self.skipWaiting()): Causes the waiting worker to advance directly into the active state.

Expected Browser Render Output


// Register Service Worker and monitor lifecycle state changes
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('[UI] SW Registered with scope:', registration.scope);

      // Check if an update is already waiting
      if (registration.waiting) {
        notifyUserOfUpdate(registration.waiting);
      }

      // Detect future updates
      registration.addEventListener('updatefound', () => {
        const installingWorker = registration.installing;
        console.log('[UI] New service worker installing...');

        installingWorker.addEventListener('statechange', () => {
          if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
            console.log('[UI] New version installed and waiting for activation.');
            notifyUserOfUpdate(installingWorker);
          }
        });
      });

    } catch (error) {
      console.error('[UI] SW registration failed:', error);
    }
  });

  // Listen for the controllerchange event when a new SW activates
  let refreshing = false;
  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (!refreshing) {
      refreshing = true;
      console.log('[UI] Controller changed! Auto-reloading client to load fresh assets.');
      window.location.reload();
    }
  });
}

function notifyUserOfUpdate(worker) {
  const updateBanner = document.createElement('div');
  updateBanner.style.cssText = 'position:fixed;bottom:20px;right:20px;background:#2563eb;color:white;padding:1rem;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.3);z-index:9999;';
  updateBanner.innerHTML = `
    <span>A new version is available!</span>
    <button id="reload-btn" style="margin-left:10px;background:white;color:#2563eb;border:none;padding:5px 10px;border-radius:4px;cursor:pointer;font-weight:bold;">Update Now</button>
  `;
  document.body.appendChild(updateBanner);

  document.getElementById('reload-btn').addEventListener('click', () => {
    // Send postMessage to tell waiting worker to call skipWaiting()
    worker.postMessage({ type: 'SKIP_WAITING' });
  });
}
const CACHE_NAME = 'app-cache-v2';
const ASSETS_TO_CACHE = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/logo.svg'
];

// 1. INSTALL PHASE: Precache assets
self.addEventListener('install', (event) => {
  console.log('[SW] Install event triggered. Caching static shell...');
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(ASSETS_TO_CACHE);
    })
  );
});

// 2. ACTIVATE PHASE: Delete old cache versions
self.addEventListener('activate', (event) => {
  console.log('[SW] Activate event triggered. Cleaning legacy caches...');
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((name) => {
          if (name !== CACHE_NAME) {
            console.log('[SW] Deleting obsolete cache bucket:', name);
            return caches.delete(name);
          }
        })
      );
    }).then(() => {
      // Claim all open clients immediately
      console.log('[SW] Claiming clients...');
      return self.clients.claim();
    })
  );
});

// 3. LISTEN FOR SKIP_WAITING MESSAGE
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    console.log('[SW] Received SKIP_WAITING signal. Activating immediately...');
    self.skipWaiting();
  }
});

// 4. FETCH PHASE: Intercept network traffic
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      return cachedResponse || fetch(event.request);
    })
  );
});
[UI] SW Registered with scope: https://example.com/
[SW] Install event triggered. Caching static shell...
[SW] Activate event triggered. Cleaning legacy caches...
[SW] Deleting obsolete cache bucket: app-cache-v1
[SW] Claiming clients...

(When user clicks [Update Now] banner):
[SW] Received SKIP_WAITING signal. Activating immediately...
[UI] Controller changed! Auto-reloading client to load fresh assets.

🏋️ Hands-On Exercise

🎯 The Challenge: Build a Resilient Cache-Migration Service Worker

Instructions:

  1. Create a Service Worker file sw.js declaring a cache name v3-production-cache.
  2. In the install event, cache three resources: ['/', '/index.html', '/app.js']. Use event.waitUntil().
  3. In the activate event, iterate through all existing cache keys using caches.keys(). Purge any cache whose name does not match v3-production-cache.
  4. Call self.clients.claim() at the conclusion of the activation lifecycle.

🏁 Starter Code Sandbox

⚠️ Common Pitfalls

  1. Placing sw.js in a Subfolder (/js/sw.js): By default, a Service Worker located at /js/sw.js can only control URLs starting with /js/. It will never intercept requests for /index.html or /api/. Always place sw.js at the root domain (/sw.js).
  2. Setting Aggressive HTTP Cache Headers on sw.js: If your web server serves sw.js with Cache-Control: max-age=31536000, the browser will not check your origin server for updates to the Service Worker itself. Always configure your HTTP server to serve sw.js with Cache-Control: no-cache, no-store, must-revalidate.
  3. Calling self.skipWaiting() Unconditionally on Every Install: If your new Service Worker changes lazy chunk hashing or API schemas, forcing an immediate upgrade while a user is in the middle of filling out a multi-step form can cause unexpected JavaScript runtime exceptions. Prompt the user with a UI banner instead.

💡 Pro Tips

  1. Prevent Refresh Loops with let refreshing = false: When listening to navigator.serviceWorker.addEventListener('controllerchange', ...), multiple tabs or rapid worker transitions can trigger multiple event dispatches. Guard against endless reload loops using a boolean flag.
  2. Leverage Chrome DevTools "Update on Reload": During active local development, enable Chrome DevTools > Application > Service Workers > Update on reload. This forces the browser to fetch a fresh sw.js on every page refresh, avoiding manual unregistering.

📌 Key Takeaways

  • Service Workers execute in an isolated background thread without synchronous access to the DOM or window object.
  • The lifecycle progresses through Parsed, Installing, Installed / Waiting, Activating, Active, and Redundant.
  • If a single resource fails to download during cache.addAll(), the install phase rejects and the worker becomes redundant.
  • self.skipWaiting() bypasses the waiting room, while self.clients.claim() assumes control of all active clients immediately upon activation.
  • Cache cleanup must always be executed during the activate event, never during install, to prevent corrupting the active worker's cache.
  • --
⭐ LEARN: HTML 🌟 ⚔️ QUIZ BATTLE ARENA // ACTIVE
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 1 / 3

Why should cache purging of old versions be executed in the activate event handler rather than the install event handler?

Question 1 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 2 / 3

What is the default maximum scope of a Service Worker script served from the path https://example.com/assets/js/sw.js without custom HTTP headers?

Question 2 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP
3x
STREAK!
BONUS ACTIVE
COMBO
? Question 3 / 3

Which method instructs a freshly activated Service Worker to immediately start intercepting network fetch events for all open tabs without waiting for the user to reload the page?

Question 3 / 3 Topic: HTML Fundamentals
00:45 REMAINING
XP REWARD
+250 XP