Day 53: PWA Mechanics & Service Workers
Understand the Service Worker lifecycle and caching strategies well enough to build a PWA that reliably works offline without serving stale content forever.
Study
Concepts
A Service Worker is a programmable network proxy, running separately from your page
A Service Worker is a JS file that runs in its own thread, separate from any page, and can intercept every network request the page makes via the `fetch` event — this is what makes offline support, custom caching strategies, and push notifications possible: the Service Worker decides whether to serve from cache, hit the network, or some combination, entirely programmatically. It has its own lifecycle independent of any open tab: `install` (typically used to pre-cache a set of assets — the "app shell"), `activate` (a good place to clean up old cache versions), and then it sits idle, waking up to handle `fetch`/`push`/`sync` events as they occur, even (for `push`) when no tab is open at all.
A new Service Worker version does not take over immediately — by default it installs in the background and stays "waiting" until all tabs using the OLD version are closed, to avoid a page having code that suddenly runs against an incompatible newly-cached asset mid-session. `self.skipWaiting()` (in the SW) and `clients.claim()` opt into taking over immediately, at the cost of a live tab occasionally seeing an update mid-session unexpectedly — a deliberate tradeoff, usually paired with a "New version available, refresh?" UI prompt.
Caching strategies: pick per resource type
Cache-first: check cache, fall back to network — best for immutable, versioned assets (hashed JS/CSS bundles) that never change once cached. Network-first: try network, fall back to cache on failure — best for frequently-changing data (an API response) where freshness matters more than speed, but offline availability is still valuable as a fallback. Stale-while-revalidate: serve the cached version INSTANTLY while simultaneously fetching a fresh copy in the background to update the cache for next time — a good default for most non-critical, semi-frequently-changing assets (a user avatar, a list of categories) where instant response matters more than perfect real-time freshness.
The Cache API (used directly, or via a helper library like Workbox which wraps these strategies as named recipes) is distinct from the browser's HTTP cache (Day 44) — it is entirely programmatic storage the Service Worker controls explicitly, keyed by request, not governed by Cache-Control headers.
See It
Visualizations
Visualization
Service Worker lifecycle
register()
Browser downloads and parses the SW script
install
Pre-cache the app shell
runs once per new SW version
waiting
New SW installed but NOT yet active
old SW still controls open tabs
activate
Old caches cleaned up, new SW takes control
after all old tabs close, or skipWaiting()
idle → fetch/push/sync
Wakes up per event, even with no tab open
Visualization
Caching strategy per resource type
| Cache-first | Network-first | |
|---|---|---|
| Best for | Immutable, hashed build assets | Frequently-changing API data |
| Speed | Instant (no network wait) | As fast as the network allows |
| Freshness | Only as fresh as cache-time (never re-checked) | Always fresh when online |
| Offline behavior | Works perfectly | Falls back to last cached response |
Build It
Code Examples
A minimal Service Worker: pre-cache the app shell, serve cache-first
const CACHE_NAME = 'app-shell-v3'; // bump this string to invalidate old caches
const APP_SHELL = ['/', '/index.html', '/styles.css', '/app.bundle.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => cached ?? fetch(event.request))
);
});Stale-while-revalidate for a semi-frequently-changing resource
async function staleWhileRevalidate(request) {
const cache = await caches.open('data-cache-v1');
const cached = await cache.match(request);
const networkFetch = fetch(request).then((response) => {
cache.put(request, response.clone()); // update cache for NEXT time
return response;
});
return cached ?? networkFetch; // instant if cached, otherwise wait for network
}
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/categories')) {
event.respondWith(staleWhileRevalidate(event.request));
}
});Remember
Key Takeaways
- A Service Worker is a programmable proxy for every network request, running independent of any open tab.
- install pre-caches the app shell; activate is the right place to clean up old cache versions by name.
- A new SW stays "waiting" until old tabs close, unless you explicitly opt into skipWaiting()/clients.claim().
- Cache-first for immutable assets, network-first for freshness-critical data, stale-while-revalidate as a good default balance.
- The Cache API is explicit, programmatic storage the SW controls — distinct from the HTTP cache governed by Cache-Control headers (Day 44).
Do It
Practice
- 1Register the app-shell Service Worker example on a small static site, go offline in DevTools, and confirm the app still loads.
- 2Bump the CACHE_NAME, reload, and verify old caches are deleted on activate (check Application tab → Cache Storage).
- 3Implement stale-while-revalidate for one real API call and log both the instant cached response and the later network update.