◐ ai-teacher

🎨 Frontend & the Browser · Chapter 3

PWAs — manifest, service worker, offline

A service worker is a proxy you wrote, running inside the browser, that sees every request your page makes — including the ones that would have failed.

Study this lesson free in the app →

The basics

A PWA (progressive web app) is an ordinary website that can be installed to a phone's home screen and keep working offline, using two pieces: a web app manifest and a service worker.

The problem it solves: a normal website is useless the moment the network drops — in a metro tunnel, on a flight, on patchy 4G in a small town. Native apps work offline but need an app store and a separate codebase. A PWA gets most of that from a JSON file and a few dozen lines of JavaScript.

The mental model: the service worker is a proxy you wrote, sitting between your page and the network. Every request the page makes goes through its fetch handler, which can go to the network, answer from its own Cache Storage, or both. It outlives the page and is updated through a strict lifecycle — which is why it's also the easiest way to strand users on an old build.

Words you must know 5

Manifest
JSON with name, icons and display: standalone, so it installs like an app
Service worker
a script in its own thread that intercepts every request for its origin
Network-first / cache-first
try the network or the cache first; correctness vs speed
Lifecycle
install → waiting → activate; a new worker waits until old tabs close
Secure context
HTTPS or localhost; required for service workers

Explained

Add this app to your home screen and turn on aeroplane mode. Every lesson still opens.

No native app, no app store, no framework. A JSON file and about forty lines of JavaScript.

That same forty lines is also the most reliable way to ship a bug that users cannot escape from.

Two pieces do the work.

The manifest — a JSON file describing the app: name, icons, theme colour, and display: standalone so it opens without browser chrome. This is what makes Add to Home Screen produce something that looks like an app.

The service worker — a script that runs in its own thread, separate from the page, and stays alive between visits. It sits between the page and the network and can answer any request itself.

That is genuine power, which is why it comes with genuine constraints: HTTPS only (localhost excepted), no DOM access, and it can be killed and restarted at any moment.

Read the shape rather than the syntax: try the network, keep a copy, fall back to the copy.

That ordering is a deliberate trade. A cache-first worker is faster — it answers from disk instantly — but it means a user who loaded a broken build serves that broken build to themselves forever.

Network-first costs a round trip and guarantees that a deploy actually reaches people. For a study app where content changes often and milliseconds do not matter, that is the right way round. For an offline-first field tool, it would not be.

Note res.clone(). A response body is a stream and can only be consumed once — cache the clone, return the original. Getting this wrong produces the memorable error "body stream already read".

The lifecycle, and why your update did not appear

install → waiting → activate → fetch

The trap is waiting. When you deploy a new sw.js, the browser installs it — and then it waits, because the old worker still controls the open tab. It does not take over until every tab of that origin is closed. A refresh is not enough.

Two lines skip the queue:

self.skipWaiting();    // in install — do not wait for tabs to close
self.clients.claim();  // in activate — take control of open pages now

This app calls both. Without them, "I deployed but nothing changed" is not a caching bug — it is the lifecycle working as designed, and you can watch it in DevTools → Application → Service Workers.

Versioning the cache — the part you must not forget

const CACHE = 'ai-teacher-v4';   // bumped from v3 when handbook.js was added

self.addEventListener('activate', (e) => {
  e.waitUntil(
    caches.keys()
      .then((keys) => Promise.all(
        keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
      ))
      .then(() => self.clients.claim())
  );
});

The cache name is the version. Bump it and the activate handler deletes every cache that is not the current one.

Skip the bump and old entries linger indefinitely — including files you have since deleted. Treat the version bump as part of shipping, not as an afterthought.

What happens to a request

The last step matters for a hash-routed app: any unknown URL still returns the shell, and the router works out what to show from the hash.

Cache-first without a version strategy. The canonical way to ship an unrecoverable bug. Users are pinned to a broken build and clearing site data is the only escape.

Forgetting to bump CACHE. Stale files survive, including deleted ones.

Caching POST requests. The Cache API only supports GET. This app guards with if (e.request.method !== 'GET') return;.

Not calling res.clone(). "Body stream already read."

Testing over http:// on a LAN IP. Service workers require a secure origin — localhost is exempt, 192.168.0.183 is not. This is precisely why offline mode never worked on this app until it was deployed to HTTPS.

Debugging without unregistering. Half of "my fix isn't showing" is an old worker still in control.

A service worker is a proxy you wrote, that outlives your page.

Network-first = correctness. Cache-first = speed, and the risk of stranding users.

Version the cache name, delete old ones on activate, and call skipWaiting() + clients.claim() — otherwise your update sits in waiting until every tab closes.

Words to know

Service workerA script running in its own thread that proxies network requests for an origin, and persists between visits.
Web app manifestJSON describing name, icons and display mode, so the app installs to a home screen.
Secure contextHTTPS, or localhost. Required for service workers and most powerful browser APIs.
skipWaitingTells a newly installed worker not to wait for open tabs to close before activating.
Cache StorageThe service-worker-controlled store of Request/Response pairs. GET only.
Network-firstTry the network, fall back to cache. Favours correctness over speed.

In two minutes

Test yourself

1. Why does a service worker not register on http://192.168.0.183?

  1. The port is wrong
  2. It requires a secure context — HTTPS or localhost
  3. LAN IPs are blocked
  4. The manifest is missing
Show the answer

It requires a secure context — HTTPS or localhost — localhost is exempt for development; any other origin needs HTTPS. This is why offline mode only worked after deploying.

2. You deployed a new sw.js but the old one is still running. Why?

  1. The deploy failed
  2. A new worker waits until all tabs of the origin close, unless skipWaiting is called
  3. Service workers update weekly
  4. The cache name did not change
Show the answer

A new worker waits until all tabs of the origin close, unless skipWaiting is called — That is the waiting state. skipWaiting() plus clients.claim() takes over immediately.

3. What does network-first buy you over cache-first?

  1. Faster first paint
  2. Guaranteed that a deploy reaches users
  3. Less storage
  4. Works without HTTPS
Show the answer

Guaranteed that a deploy reaches users — Cache-first can pin a user to a broken build indefinitely. Network-first costs a round trip and always tries fresh.

4. What makes an installed PWA open in its own app window, without the browser's address bar?

  1. `display: standalone` in the web app manifest
  2. Calling `skipWaiting()`
  3. A `<meta name="viewport">` tag
  4. `Cache-Control: immutable` on index.html
Show the answer

`display: standalone` in the web app manifest — The manifest's display field controls the window mode. The viewport tag only controls scaling, and service worker calls have nothing to do with how the window looks.

5. A network-first worker caches every response it fetches, without checking res.ok. During a 10-minute outage the server returns 503 for /content/manifest.json. Later the user goes offline. What do they get for that file?

  1. The last good copy from before the outage
  2. `index.html` as a fallback
  3. The cached 503 — it overwrote the good copy, because the handler stored whatever came back
  4. The browser's offline dinosaur
Show the answer

The cached 503 — it overwrote the good copy, because the handler stored whatever came back — fetch() only rejects on network failure; a 503 is a successful fetch of an error. The handler put it over the good entry. Guard with if (res.ok) before caching — this app hit a version of exactly this with a cached 503 from /api.

6. A response carries Cache-Control: no-store. Does that stop your service worker from putting it in Cache Storage?

  1. Yes — the browser refuses to store it
  2. No — Cache Storage is controlled by your code and stores whatever you `put()`
  3. Only in Safari
  4. Only for HTML responses
Show the answer

No — Cache Storage is controlled by your code and stores whatever you `put()` — HTTP caching headers govern the browser's HTTP cache. The Cache API is a programmable store and ignores them. If something must never be cached by the worker, the worker's code has to skip it — which is why this app returns early for /api/.

There are 8 questions, 3 puzzles and 6 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →