Service Workers and PWAs

Reviewed & published by Brayan K

A service worker is a background script that the browser runs separately from your web page, acting as a programmable network proxy that can cache responses and serve your app offline — the foundation of Progressive Web Apps (PWAs).

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: Service workers require a secure context and a real server. The snippets below are illustrative. For the best experience:

📮 Real-World Analogy: A service worker is like a helpful mailroom clerk for your app:

Service workers are the engine behind Progressive Web Apps: web apps that load fast, work offline, and can be installed to the home screen like native apps. A service worker runs in its own thread, has no access to the DOM, and stays event-driven — the browser wakes it to handle events like install, activate, and fetch, then lets it sleep. Because it can intercept every network request, it must be served over HTTPS.

Lifecycle EventWhen It FiresTypical Use
installFirst registration / new versionPre-cache the app shell
activateAfter install, when taking controlClean up old caches
fetchOn every network request in scopeServe from cache or network

⚠️ Before you press Run: most of the snippets in this lesson are the real thing — code that belongs in sw.js on a real server. The editor has no self, no install event and no caches for them to hook into, so they print nothing when you run them. Read those for the shape of the API. The three exercises further down (the mini service worker, Your Turn, and the mini-challenge) are self-contained simulations that do run and print, so you can watch the caching decisions play out.

🔥 Registering a Service Worker

From your page, register the worker with navigator.serviceWorker.register(). Always feature-detect first, and register after load so it does not compete with critical page resources.

// Runs in the page (not the worker)
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker
      .register("/sw.js")
      .then((reg) => {
        console.log("SW registered. Scope:", reg.scope);
      })
      .catch((err) => {
        console.error("SW registration failed:", err);
      });
  });
} else {
  console.log("Service workers not supported in this browser");
}

The returned registration's scope tells you which pages this worker controls — by default, the folder the script lives in and everything beneath it.

🔥 The Install Event: Pre-Caching

Inside the worker (sw.js), the install event is where you open a cache and store the core files. Wrap async work in event.waitUntil() so the browser knows installation is not finished until caching completes.

// This code lives inside sw.js
const CACHE_NAME = "app-shell-v1";
const APP_SHELL = [
  "/",
  "/index.html",
  "/styles.css",
  "/app.js",
  "/offline.html"
];

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

cache.addAll() fetches and stores every URL in the array. If any request fails, installation fails — so keep the pre-cache list to essential, reliable assets.

🔥 The Activate Event: Cleaning Old Caches

When a new worker activates, old caches from previous versions should be deleted. The activate event is the right place, because the previous worker is no longer in control.

// Inside sw.js
const CACHE_NAME = "app-shell-v1";

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((oldKey) => {
            console.log("Deleting old cache:", oldKey);
            return caches.delete(oldKey);
          })
      )
    )
  );
});

Bumping the cache name (for example to app-shell-v2) on each release lets the activate handler automatically purge stale assets.

🔥 The Fetch Event and Cache API

The fetch event lets the worker intercept requests. With event.respondWith() you decide the response — often by checking the cache first via caches.match().

// Inside sw.js - cache-first
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      // Return cached response if we have it
      if (cached) {
        return cached;
      }
      // Otherwise fetch from the network
      return fetch(event.request);
    })
  );
});

Cache-first is great for static assets that rarely change. It returns instantly from storage and only touches the network on a miss.

Worked example: a service worker you can actually run

The snippet above is correct but silent — the editor cannot fire an install event at it. So here is the same strategy wired to a miniature, synchronous stand-in for caches and fetch. The plumbing is fake; every decision your worker makes is real. Run it, then read the two blocks of output side by side: the same five requests, once with a network and once without.

// ---- Stand-ins for the browser APIs, so this runs right here ---------
// The real Cache API is asynchronous and only exists inside a service
// worker. These synchronous stand-ins keep the DECISION logic identical
// while letting the whole story print in order.

const CACHE_NAME = 'app-shell-v1';

const cacheStorage = {              // stands in for the global 'caches'
  stores: {},
  open(name) {
    if (!this.stores[name]) this.stores[name] = {};
    const store = this.stores[name];
    return {
      addAll(urls) { urls.forEach(u => { store[u] = 'cached ' + u; }); },
      put(url, body) { store[url] = body; }
    };
  },
  match(url) {                      // caches.match: the body, or undefined
    for (const name in this.stores) {
      if (url in this.stores[name]) return this.stores[name][url];
    }
    return undefined;               // a cache MISS
  }
};

let networkUp = true;               // flip this to simulate aeroplane mode
function fakeFetch(url) {
  if (!networkUp) throw new Error('network unreachable');
  return 'fresh ' + url;
}

// ---- install: pre-cache the app shell --------------------------------
const APP_SHELL = ['/', '/styles.css', '/app.js', '/offline.html'];
cacheStorage.open(CACHE_NAME).addAll(APP_SHELL);
console.log('install: pre-cached ' + APP_SHELL.length + ' files into ' + CACHE_NAME);

// ---- fetch: the cache-first strategy ---------------------------------
function onFetch(url) {
  const cached = cacheStorage.match(url);        // caches.match(event.request)
  if (cached) {
    return 'CACHE   | ' + cached;                // hit: instant, works offline
  }
  try {
    const fresh = fakeFetch(url);                // miss: ask the network
    cacheStorage.open('runtime').put(url, fresh);// and keep a copy for next time
    return 'NETWORK | ' + fresh;
  } catch (err) {
    return 'OFFLINE | ' + cacheStorage.match('/offline.html');
  }
}

// ---- Drive it: a first visit, then the same app with no signal -------
console.log('--- online ---');
for (const url of ['/', '/styles.css', '/api/news', '/api/news', '/logo.png']) {
  console.log(url + ' -> ' + onFetch(url));
}

networkUp = false;
console.log('--- offline (aeroplane mode) ---');
for (const url of ['/', '/api/news', '/help/never-visited.html']) {
  console.log(url + ' -> ' + onFetch(url));
}

// ✅ Expected output:
// install: pre-cached 4 files into app-shell-v1
// --- online ---
// / -> CACHE   | cached /
// /styles.css -> CACHE   | cached /styles.css
// /api/news -> NETWORK | fresh /api/news
// /api/news -> CACHE   | fresh /api/news
// /logo.png -> NETWORK | fresh /logo.png
// --- offline (aeroplane mode) ---
// / -> CACHE   | cached /
// /api/news -> CACHE   | fresh /api/news
// /help/never-visited.html -> OFFLINE | cached /offline.html
//
// Two things to notice. First, the app still works offline — that is the
// whole point. Second, look at the fourth line: the SECOND request for
// /api/news came from the cache while the network was perfectly healthy.
// Cache-first serves stale news. That is exactly the bug the next section
// fixes.

🔥 Network-First with Offline Fallback

For content that should be fresh (like API data or HTML), use network-first: try the network, and fall back to the cache (or an offline page) when the request fails.

// Inside sw.js - network-first with offline fallback
self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Optionally update the cache with the fresh response
        const copy = response.clone();
        caches.open("runtime").then((cache) =>
          cache.put(event.request, copy)
        );
        return response;
      })
      .catch(() =>
        // Network failed - serve cached version or offline page
        caches.match(event.request).then(
          (cached) => cached || caches.match("/offline.html")
        )
      )
  );
});

Note the response.clone(): a response body can only be read once, so you clone it to both return it and store it. This pattern keeps users online-fresh but offline-capable.

🎯 Your turn: flip the strategy round

Network-first is cache-first with the two branches swapped: try the network, and only reach for the cache when it fails. The stand-in plumbing below is already written for you — only the two lines that decide where the response comes from are missing.

// 🎯 YOUR TURN — fill in the blanks marked with ___
// Everything above onFetch() is the same stand-in harness as before.

const CACHE_NAME = 'app-shell-v1';
const cacheStorage = {
  stores: {},
  open(name) {
    if (!this.stores[name]) this.stores[name] = {};
    const store = this.stores[name];
    return {
      addAll(urls) { urls.forEach(u => { store[u] = 'cached ' + u; }); },
      put(url, body) { store[url] = body; }
    };
  },
  match(url) {
    for (const name in this.stores) {
      if (url in this.stores[name]) return this.stores[name][url];
    }
    return undefined;
  }
};
let networkUp = true;
function fakeFetch(url) {
  if (!networkUp) throw new Error('network unreachable');
  return 'fresh ' + url;
}
cacheStorage.open(CACHE_NAME).addAll(['/', '/styles.css', '/app.js', '/offline.html']);

function onFetch(url) {
  try {
    // 1) Network FIRST — ask the server before you look in the cache
    const fresh = ___;                              // 👉 fakeFetch(url)
    cacheStorage.open('runtime').put(url, fresh);   // keep a copy for later
    return 'NETWORK | ' + fresh;
  } catch (err) {
    // 2) The network failed. Now the cache is the fallback, not the default
    const cached = ___;                             // 👉 cacheStorage.match(url)
    return cached
      ? 'CACHE   | ' + cached
      : 'OFFLINE | ' + cacheStorage.match('/offline.html');
  }
}

console.log('--- online ---');
for (const url of ['/', '/api/news', '/api/news']) {
  console.log(url + ' -> ' + onFetch(url));
}
networkUp = false;
console.log('--- offline (aeroplane mode) ---');
for (const url of ['/', '/api/news', '/help/never-visited.html']) {
  console.log(url + ' -> ' + onFetch(url));
}

// ✅ Expected output once both blanks are filled:
// --- online ---
// / -> NETWORK | fresh /
// /api/news -> NETWORK | fresh /api/news
// /api/news -> NETWORK | fresh /api/news
// --- offline (aeroplane mode) ---
// / -> CACHE   | cached /
// /api/news -> CACHE   | fresh /api/news
// /help/never-visited.html -> OFFLINE | cached /offline.html
//
// Compare with the cache-first run: online, every line now says NETWORK,
// so the news is never stale — and the cost is a network round trip even
// for '/', which never changes. That trade-off is why real apps use
// cache-first for assets and network-first for data.

🔥 The Web App Manifest and Installability

A PWA becomes installable when you provide a web app manifest — a JSON file describing the app — and link it from your HTML. Combined with a service worker over HTTPS, browsers offer an "Install" prompt.

// manifest.json (linked via <link rel="manifest" href="/manifest.json">)
const manifest = {
  name: "My Progressive Web App",
  short_name: "MyPWA",
  start_url: "/",
  display: "standalone",
  background_color: "#ffffff",
  theme_color: "#0d9488",
  icons: [
    { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
    { src: "/icon-512.png", sizes: "512x512", type: "image/png" }
  ]
};

console.log("Installable checklist:");
console.log("- Valid manifest:", manifest.name, manifest.start_url);
console.log("- display:", manifest.display);
console.log("- Plus: a registered service worker served over HTTPS");

// ✅ Expected output (this one really does run in the editor):
// Installable checklist:
// - Valid manifest: My Progressive Web App /
// - display: standalone
// - Plus: a registered service worker served over HTTPS

Key fields: name/short_name, a start_url, a display mode like standalone, and appropriately sized icons (at least 192px and 512px). Installed PWAs launch in their own window without browser chrome.

🔥 Scope and the HTTPS Requirement

A worker registered at /app/sw.js controls only /app/ and below. You can narrow scope with the scope option, but you cannot widen it beyond the script's directory. And because the worker can rewrite traffic, the origin must be secure.

// Registering with an explicit, narrower scope
navigator.serviceWorker.register("/sw.js", { scope: "/app/" });

// Security check (the browser enforces this automatically)
const origin = "https://example.com";
const isSecure =
  origin.startsWith("https://") || origin.startsWith("http://localhost");

console.log("Secure context required for service workers:", isSecure);
console.log("A /sw.js worker defaults to scope: '/'");
console.log("A /app/sw.js worker is limited to '/app/' and below");

// ✅ Expected output — the three console.log lines below really do print.
//    The register() call quietly fails in the background because there is
//    no /sw.js to register. (On a page served over plain http:// rather
//    than https:// or localhost, navigator.serviceWorker does not exist at
//    all and that line throws a TypeError instead of printing anything.)
// Secure context required for service workers: true
// A /sw.js worker defaults to scope: '/'
// A /app/sw.js worker is limited to '/app/' and below

HTTPS prevents man-in-the-middle attackers from hijacking the proxy. localhost is exempt so you can develop without a certificate.

🔥 Service Workers vs Web Workers

Both run off the main thread and cannot touch the DOM, but they solve different problems. A Web Worker offloads CPU-heavy work for a single page. A service worker is a persistent, event-driven network proxy shared across all pages in its scope.

AspectWeb WorkerService Worker
Main purposeOffload CPU-heavy tasksProxy network, cache, offline
LifetimeTied to its pagePersists across page loads
Intercept requestsNoYes (fetch event)
ScopeOne pageAll pages in its URL scope

🔥 Common Service Worker Mistakes to Avoid

❌ Anti-Patterns

🎯 Mini-Challenge: Stale-While-Revalidate

Cache-first is fast but stale. Network-first is fresh but slow. Stale-while-revalidate refuses to choose: it hands back the cached copy immediately, and updates the cache in the background so the next request is fresh. It is what most news feeds, avatars and product listings use.

Write onFetch yourself this time — only the outline is given. The fake fetch now stamps a version number on every response, so you can see the cache running exactly one step behind.

// Same harness, but fakeFetch stamps a version so you can spot staleness
const CACHE_NAME = 'app-shell-v1';
const cacheStorage = {
  stores: {},
  open(name) {
    if (!this.stores[name]) this.stores[name] = {};
    const store = this.stores[name];
    return {
      addAll(urls) { urls.forEach(u => { store[u] = 'cached ' + u; }); },
      put(url, body) { store[url] = body; }
    };
  },
  match(url) {
    for (const name in this.stores) {
      if (url in this.stores[name]) return this.stores[name][url];
    }
    return undefined;
  }
};
let networkUp = true;
let version = 0;
function fakeFetch(url) {
  if (!networkUp) throw new Error('network unreachable');
  version++;
  return url + ' v' + version;      // every fetch returns a NEWER version
}
cacheStorage.open(CACHE_NAME).addAll(['/', '/offline.html']);

// 🎯 MINI-CHALLENGE: write the stale-while-revalidate strategy
// 1. Look in the cache first and store the result in 'cached'.
// 2. Then try to fetch a fresh copy and put() it in the 'runtime' cache.
//    Wrap that in try/catch — being offline must NOT break the response.
//    Set fresh to undefined in the catch.
// 3. If 'cached' has something, return 'CACHE   | ' + cached  (the stale
//    copy, served instantly — the revalidate already happened above).
// 4. Otherwise, if 'fresh' has something, return 'NETWORK | ' + fresh.
// 5. Otherwise return 'OFFLINE | ' + cacheStorage.match('/offline.html').
function onFetch(url) {
  // your code here
}

console.log('--- online ---');
for (const url of ['/api/news', '/api/news', '/api/news']) {
  console.log(url + ' -> ' + onFetch(url));
}
networkUp = false;
console.log('--- offline ---');
for (const url of ['/api/news', '/help/never-visited.html']) {
  console.log(url + ' -> ' + onFetch(url));
}

// ✅ Expected output when your version is right:
// --- online ---
// /api/news -> NETWORK | /api/news v1
// /api/news -> CACHE   | /api/news v1
// /api/news -> CACHE   | /api/news v2
// --- offline ---
// /api/news -> CACHE   | /api/news v3
// /help/never-visited.html -> OFFLINE | cached /offline.html
//
// Read the version numbers: the user always gets an answer instantly, and
// the cache is always exactly one revalidation ahead of what they saw.
// If every line says NETWORK you returned 'fresh' before checking 'cached'.
// ❌ TypeError: Cannot read properties of undefined means onFetch returned
// nothing — every branch needs its own return.

🎯 Key Takeaways

Practice quiz

What is a service worker?

  • A script the browser runs in the background, separate from the page, acting as a network proxy
  • A type of database
  • A server-side rendering engine
  • A CSS preprocessor

Answer: A script the browser runs in the background, separate from the page, acting as a network proxy. A service worker is a background script that runs separately from the web page and can intercept network requests, acting as a programmable proxy.

Which API registers a service worker?

  • document.registerSW()
  • serviceWorker.start()
  • navigator.serviceWorker.register()
  • window.installWorker()

Answer: navigator.serviceWorker.register(). You register a service worker by calling navigator.serviceWorker.register('/sw.js'), usually after the page loads.

What is the correct order of the core service worker lifecycle events?

  • activate, install, fetch
  • install, activate, fetch
  • fetch, activate, install
  • fetch, install, activate

Answer: install, activate, fetch. A service worker first installs (good for pre-caching), then activates (good for cleaning old caches), then handles fetch events.

During which event do you typically pre-cache the app shell?

  • The fetch event
  • The message event
  • The activate event
  • The install event

Answer: The install event. The install event is the ideal place to open a cache and pre-cache the core app shell with event.waitUntil().

Which method looks for a matching cached response?

  • caches.match()
  • storage.lookup()
  • cache.find()
  • fetch.cached()

Answer: caches.match(). caches.match(request) searches the Cache Storage for a response that matches the request and returns it if found.

What does a 'cache-first' strategy do?

  • It only ever uses the network
  • It serves from cache if available, falling back to the network
  • It deletes the cache on every request
  • It always ignores the cache

Answer: It serves from cache if available, falling back to the network. Cache-first returns a cached response immediately when present and only goes to the network on a cache miss, enabling fast offline-capable loads.

What makes a Progressive Web App installable?

  • A large image file
  • A single HTML file with no JavaScript
  • Disabling the cache
  • A valid web app manifest plus a registered service worker served over HTTPS

Answer: A valid web app manifest plus a registered service worker served over HTTPS. Installability requires a web app manifest (name, icons, start_url, display), a service worker, and a secure (HTTPS) origin.

What security requirement do service workers have?

  • They only work in incognito mode
  • They must be inline in the HTML
  • They must be served over HTTPS (localhost is exempt for development)
  • They require a paid certificate from the browser vendor

Answer: They must be served over HTTPS (localhost is exempt for development). Because they can intercept network traffic, service workers require a secure context (HTTPS), with localhost allowed for development.

What does a service worker's scope control?

  • The size of the cache only
  • Which URLs/pages the service worker can control and intercept
  • The number of tabs allowed
  • The colors of the page

Answer: Which URLs/pages the service worker can control and intercept. Scope defines the set of pages a service worker controls; by default it is the directory of the script and cannot be broader than that path.

How does a service worker differ from a regular Web Worker?

  • A service worker is event-driven, persists across page loads, and can intercept network requests, while a Web Worker runs tied to a page for CPU work
  • They are identical
  • A Web Worker can intercept network requests but a service worker cannot
  • A service worker can directly manipulate the DOM

Answer: A service worker is event-driven, persists across page loads, and can intercept network requests, while a Web Worker runs tied to a page for CPU work. A Web Worker offloads CPU work for a single page; a service worker is a persistent, event-driven network proxy shared across pages. Neither can touch the DOM directly.

Continue this course